diff --git a/.github/actions/resolve-release-branch/README.md b/.github/actions/resolve-release-branch/README.md new file mode 100644 index 0000000..804ca8d --- /dev/null +++ b/.github/actions/resolve-release-branch/README.md @@ -0,0 +1,77 @@ +# Resolve Release Branch Action + +Resolves the maintenance branch a given project version belongs to. + +## Description + +Three workflows need the same answer to the same question — *which branch does this version +live on?* — and until this action existed they each answered it with their own copy of the +same 60 lines. `post-release.yml` needs it to merge a release branch back, `update-versions.yml` +to push a version bump, and `create-oss-release-branch.yml` to know which OSS branch to cut a +release from. + +The rule is: + +1. Take the version's line and try `..x`. If that branch exists, use it. +2. On a **commercial** repository, stop there. Those repositories have no `main` at all — + `spring-cloud-config-commercial`'s default branch is `4.3.x` — so there is no sane + fallback to make. +3. Otherwise fall back to `main`, but only after reading `main`'s `pom.xml` and confirming it + is on the same `.` line. Without that check a version whose branch has been + deleted, or a typo, would quietly act on whatever `main` happens to be. + +Step 3 is the part worth keeping in one place. It is why a `5.1.0` release of a project whose +`5.1.x` branch does not exist yet correctly resolves to `main`, while a `3.9.9` release of the +same project is refused rather than silently rewriting `main`. + +## Inputs + +| Input | Description | Required | Default | +|-------|-------------|----------|---------| +| `repo` | Full repository path (e.g. `spring-cloud/spring-cloud-config`) | Yes | | +| `version` | The version whose line is wanted. Any qualifier is stripped first, so `5.0.4-SNAPSHOT`, `5.1.0-INTERNAL-SNAPSHOT`, `5.1.0-M1` and `5.0.3` all resolve the same way. | Yes | | +| `commercial` | Repository is a commercial one, so there is no `main` to fall back to | No | `false` | +| `token` | Token with read access to the repository | Yes | | + +## Outputs + +| Output | Description | +|--------|-------------| +| `branch` | The resolved branch, or empty when `status` is not `ok` | +| `status` | `ok`, `branch-not-found`, or `version-mismatch` | +| `message` | Why, when `status` is not `ok`. Empty otherwise. | + +## It reports, it does not fail + +An unresolvable branch exits `0` with a non-`ok` status rather than failing the step. Two of +the three callers run this across a matrix of sixteen-odd projects, and one project that +cannot be resolved should appear as a row in the run summary, not take the whole release +down. A caller that targets a single project — `create-oss-release-branch.yml` — checks the +status itself and fails there. + +## Usage + +```yaml +- name: Resolve target branch + id: branch + uses: ./.github/actions/resolve-release-branch + with: + repo: spring-cloud/spring-cloud-config + version: 5.0.4-SNAPSHOT + commercial: 'false' + token: ${{ secrets.GH_ACTIONS_REPO_TOKEN }} + +- name: Do the work + if: steps.branch.outputs.status == 'ok' + run: echo "Working on ${{ steps.branch.outputs.branch }}" +``` + +## Examples + +| Repository | Version | Result | +|---|---|---| +| `spring-cloud-config` | `5.0.5` | `5.0.x` — the branch exists | +| `spring-cloud-config` | `5.1.0` | `main` — no `5.1.x` yet, and `main` is at `5.1.0-SNAPSHOT` | +| `spring-cloud-config` | `5.1.0-M1` | `main` — the qualifier does not change the line | +| `spring-cloud-config` | `3.9.9` | `version-mismatch` — no `3.9.x`, and `main` is not on that line | +| `spring-cloud-config-commercial` | `4.9.9` | `branch-not-found` — commercial, so no `main` fallback | diff --git a/.github/actions/resolve-release-branch/action.yml b/.github/actions/resolve-release-branch/action.yml new file mode 100644 index 0000000..d9b9309 --- /dev/null +++ b/.github/actions/resolve-release-branch/action.yml @@ -0,0 +1,148 @@ +name: 'Resolve Release Branch' +description: > + Resolves the maintenance branch a given project version belongs to: ..x + when it exists, otherwise main for OSS repositories whose main is on that same line. + +inputs: + repo: + description: 'Full repository path (e.g. spring-cloud/spring-cloud-config)' + required: true + version: + description: > + The version whose line is wanted. A qualifier is stripped before the line is taken, + so 5.0.4-SNAPSHOT, 5.1.0-INTERNAL-SNAPSHOT, 5.1.0-M1 and 5.0.3 all resolve to the + same branch. + required: true + commercial: + description: > + When true the repository is a commercial one, which has no main branch to fall back + on - spring-cloud-config-commercial's default is 4.3.x - so a missing .x branch is + reported rather than guessed at. + required: false + default: 'false' + token: + description: 'GitHub token with read access to the repository' + required: true + +outputs: + branch: + description: 'The resolved branch, or empty when status is not ok' + value: ${{ steps.resolve.outputs.branch }} + status: + description: > + ok | branch-not-found | version-mismatch. Callers report these rather than failing, + so one project that cannot be resolved does not take a whole matrix down. + value: ${{ steps.resolve.outputs.status }} + message: + description: 'Why, when status is not ok. Empty otherwise.' + value: ${{ steps.resolve.outputs.message }} + +runs: + using: composite + steps: + - name: Resolve the branch + id: resolve + shell: bash + env: + GH_TOKEN: ${{ inputs.token }} + REPO: ${{ inputs.repo }} + VERSION: ${{ inputs.version }} + COMMERCIAL: ${{ inputs.commercial }} + run: | + node - << 'JSEOF' + const fs = require('fs'); + const { execFileSync } = require('child_process'); + + const repo = process.env.REPO; + const version = process.env.VERSION; + const commercial = process.env.COMMERCIAL === 'true'; + + const out = process.env.GITHUB_OUTPUT; + const emit = (k, v) => fs.appendFileSync(out, `${k}=${v}\n`); + // Never a hard failure: a caller running this across a matrix wants the one project + // that cannot be resolved reported in its summary, not the whole run red. + const stop = (status, message) => { + console.log(message); + emit('status', status); + emit('branch', ''); + emit('message', message); + process.exit(0); + }; + + // Drop the qualifier, then the last segment, and append .x. Works for every shape + // these repositories use: OSS 5.0.3 and 5.0.4-SNAPSHOT -> 5.0.x, 3-part commercial + // 4.2.8 -> 4.2.x, 5.1.0-INTERNAL-SNAPSHOT -> 5.1.x, 5.1.0-M1 -> 5.1.x. + const plain = version.replace(/-[A-Za-z].*$/, ''); + const parts = plain.split('.'); + if (parts.length < 2 || !parts.every(p => /^\d+$/.test(p))) { + stop('version-mismatch', + `ERROR: '${version}' is not a numeric version, so no branch can be derived from it.`); + } + const target = parts.slice(0, -1).join('.') + '.x'; + + const branchExists = branch => { + try { + execFileSync('gh', ['api', `repos/${repo}/branches/${branch}`, '--jq', '.name'], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + return true; + } catch (err) { return false; } + }; + + if (branchExists(target)) { + console.log(`Target branch: ${target}`); + emit('status', 'ok'); + emit('branch', target); + emit('message', ''); + process.exit(0); + } + + // Commercial repos have no main branch at all - spring-cloud-config-commercial's + // default is 4.3.x - so there is no sane fallback to make. + if (commercial) { + stop('branch-not-found', + `ERROR: ${repo} has no ${target} branch, and commercial repos have no main to ` + + 'fall back to. Skipping this project.'); + } + + if (!branchExists('main')) { + stop('branch-not-found', `ERROR: ${repo} has neither ${target} nor main.`); + } + + // Falling back to main is only safe if main really is the line this version belongs + // to - otherwise we would act on an unrelated major.minor. + const expected = parts.slice(0, 2).join('.'); + let pom; + try { + const b64 = execFileSync('gh', ['api', + `repos/${repo}/contents/pom.xml?ref=main`, '--jq', '.content'], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + pom = Buffer.from(b64.replace(/\s/g, ''), 'base64').toString('utf8'); + } catch (err) { + stop('branch-not-found', `ERROR: no ${target} branch and could not read pom.xml on main.`); + } + + // The root is the project's own; fall back to when the + // root pom inherits it. + const withoutParent = pom.replace(/[\s\S]*?<\/parent>/, ''); + let m = withoutParent.match(/([^<]+)<\/version>/); + if (!m) { + const parent = pom.match(/[\s\S]*?<\/parent>/); + if (parent) m = parent[0].match(/([^<]+)<\/version>/); + } + const pomVersion = m ? m[1].trim() : ''; + + if (!pomVersion.startsWith(`${expected}.`)) { + stop('version-mismatch', + `ERROR: ${repo} has no ${target} branch, and main is at '${pomVersion}', which is ` + + `not on the ${expected} line. Refusing to act on main for ${version}.`); + } + + console.log(`No ${target} branch; main is at ${pomVersion} - using main.`); + emit('status', 'ok'); + emit('branch', 'main'); + emit('message', ''); + JSEOF + +branding: + icon: 'git-branch' + color: 'green' diff --git a/.github/actions/spring-release-train-project-ready/README.md b/.github/actions/spring-release-train-project-ready/README.md index d42e511..3214020 100644 --- a/.github/actions/spring-release-train-project-ready/README.md +++ b/.github/actions/spring-release-train-project-ready/README.md @@ -6,23 +6,36 @@ A composite GitHub Action that prepares a Spring Cloud project for release train This action orchestrates the steps required to mark a Spring Cloud project as ready within a release train: -1. **Checkout** the `release/` branch of `spring-cloud/` -2. **Update versions** using the `update-project-versions` action, resolving all dependency versions from the jenkins-releaser-config properties file for the given release train -3. **Verify** that no pre-release versions (`-SNAPSHOT`, `-RC*`, `-M*`) remain in any Maven or Gradle build file -4. **Commit and push** the version changes (if any) with the message `"Release "` -5. **Trigger** the `release-train-ready.yml` workflow on the project's release branch -6. **Remove from Antora playbook** — removes `release/` from `content.sources.branches` in the `antora-playbook.yml` on the repo's `docs-build` branch (no-op when the docs-build branch, playbook, or branch entry is absent) +1. **Resolve the version** for this project from the release train's properties file, and confirm it has not already been released +2. **Checkout** the `release/` branch of `spring-cloud/` +3. **Update versions** using the `update-project-versions` action, resolving all dependency versions from the jenkins-releaser-config properties file for the given release train +4. **Verify** that no pre-release versions (`-SNAPSHOT`, `-RC*`, `-M*`) remain in any Maven or Gradle build file +5. **Commit and push** the version changes (if any) with the message `"Release "` +6. **Trigger** the `release-train-ready.yml` workflow on the project's release branch +7. **Remove from Antora playbook** — removes `release/` from `content.sources.branches` in the `antora-playbook.yml` on the repo's `docs-build` branch (no-op when the docs-build branch, playbook, or branch entry is absent) Release branches are not registered in `config/projects.json`, so nothing is removed from it here. The long-lived `..x-internal` branch **is** registered, and it is deregistered by [`retire-branch.yml`](../../workflows/retire-branch.yml) when the minor line is retired — not on every release. -If version verification fails (step 3), the action stops immediately — no commit, push, or workflow dispatch occurs. +If verification fails (step 4), the action stops immediately — no commit, push, or workflow dispatch occurs. + +### The version is derived, not passed in + +`spring-cloud-release-train-version` and `project` together determine everything else. This +project's entry in that train's properties file **is** the version being released — +`2026_0_0-m1.properties` says `spring-cloud-config=5.1.0-M1` — which names the +`release/5.1.0-M1` branch to check out, dispatch into and drop from the Antora playbook. +There is nothing for a separate version input to say that these two do not already fix. + +Step 1 also refuses when `v` already exists, in either the OSS or the commercial +repository. Release branches are not deleted after a release, so `release/5.0.5` is still +there long after 5.0.5 shipped; without that check, naming an already-released train would +re-stamp that branch and re-dispatch readiness for it. ## Inputs | Input | Description | Required | Default | |-------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|---------| -| `project` | The spring-cloud GitHub project name (e.g. `spring-cloud-config`). The action checks out `spring-cloud/` at `release/`. Append `-commercial` for commercial variants. | Yes | — | -| `project-version` | The version of the project being released (e.g. `4.2.0`). Identifies the `release/` branch. | Yes | — | +| `project` | The spring-cloud GitHub project name (e.g. `spring-cloud-config`). Selects the repository to act on; append `-commercial` for commercial variants. The release branch inside it is derived, not passed in. | Yes | — | | `spring-cloud-release-train-version` | The Spring Cloud release train version matching the jenkins-releaser-config properties file (e.g. `2025.0.0`). Used to resolve dependency versions. | Yes | — | | `spring-release-train-version` | The Spring release train version to mark this project ready in (e.g. `2026.07`). Passed as the `release-train` input to the project's `release-train-ready.yml` workflow. | Yes | — | | `token` | GitHub token for checkout, push, and workflow dispatch. | Yes | — | @@ -42,22 +55,38 @@ jobs: uses: spring-cloud/spring-cloud-github-actions/.github/actions/spring-release-train-project-ready@v1 with: project: spring-cloud-config - project-version: '4.2.0' spring-cloud-release-train-version: '2025.0.0' spring-release-train-version: '2026.07' token: ${{ secrets.GH_ACTIONS_REPO_TOKEN }} ``` +### Where the releaser config comes from + +**Always `spring-cloud/spring-cloud-release-commercial`, for OSS releases too** — the same +choice `post-release.yml`, `update-versions.yml`, `setup-next-release-train.yml` and +`create-oss-release-branch.yml` all make. That repository holds the releaser config for +every train now, and its train files are plain OSS train files: `2026_0_0-m1.properties` is +`spring-cloud-config=5.1.0-M1` and so on, with no commercial-only versions in it. + +The OSS repository's copy of `jenkins-releaser-config` stopped at 2025.1.3 and disagrees +with reality where the two still overlap, so reading it for an OSS release would 404 on a +current train and stamp versions that were never released on an older one. + +Both reads go there — the version check, and the `commercial: 'true'` passed to +`update-project-versions` — so the file validated against and the file stamped from are +always the same one. + ### Commercial Variant -When the project name ends in `-commercial`, the `commercial` flag is automatically set to `true` when calling `update-project-versions`, so the releaser config is fetched from `spring-cloud-release-commercial` instead of `spring-cloud-release`. +The `-commercial` suffix selects the **project repository** to check out and dispatch into, +and nothing else. It is stripped before looking the project up in the properties file, +since the config lists `spring-cloud-config` rather than `spring-cloud-config-commercial`. ```yaml - name: Mark spring-cloud-config-commercial ready in release train uses: spring-cloud/spring-cloud-github-actions/.github/actions/spring-release-train-project-ready@v1 with: project: spring-cloud-config-commercial - project-version: '4.2.0' spring-cloud-release-train-version: '2025.0.0' spring-release-train-version: '2026.07' token: ${{ secrets.GH_ACTIONS_REPO_TOKEN }} @@ -82,7 +111,6 @@ jobs: - uses: spring-cloud/spring-cloud-github-actions/.github/actions/spring-release-train-project-ready@v1 with: project: ${{ matrix.project }} - project-version: '4.2.0' spring-cloud-release-train-version: '2025.0.0' spring-release-train-version: '2026.07' token: ${{ secrets.GH_ACTIONS_REPO_TOKEN }} @@ -123,7 +151,7 @@ The action triggers `release-train-ready.yml` via `gh workflow run` with: The token provided (or the `GH_ACTIONS_REPO_TOKEN` secret) must have: - **Contents: write** on `spring-cloud/` — to push commits to the release branch - **Actions: write** on `spring-cloud/` — to dispatch the `release-train-ready.yml` workflow -- **Contents: read** on `spring-cloud/spring-cloud-release` (or `spring-cloud-release-commercial` for commercial projects) — to fetch the jenkins-releaser-config properties file +- **Contents: read** on `spring-cloud/spring-cloud-release-commercial` — to fetch the jenkins-releaser-config properties file, for OSS releases too (see [Where the releaser config comes from](#where-the-releaser-config-comes-from)) ## License diff --git a/.github/actions/spring-release-train-project-ready/action.yml b/.github/actions/spring-release-train-project-ready/action.yml index 15e3952..e581e42 100644 --- a/.github/actions/spring-release-train-project-ready/action.yml +++ b/.github/actions/spring-release-train-project-ready/action.yml @@ -10,20 +10,16 @@ inputs: project: description: > The spring-cloud GitHub project name (e.g. spring-cloud-config or - spring-cloud-config-commercial). The action checks out - spring-cloud/ at branch release/. - required: true - project-version: - description: > - The version of the project being released (e.g. 4.2.0). Used to - identify the release/ branch to check out. + spring-cloud-config-commercial). Selects the repository to act on; the + release branch inside it is derived from the release train, not passed in. required: true spring-cloud-release-train-version: description: > The Spring Cloud release train version that matches the properties file - in the jenkins-releaser-config branch of spring-cloud-release - (e.g. 2025.0.0). Passed to update-project-versions to resolve - dependency versions. + in the jenkins-releaser-config branch of spring-cloud-release-commercial + (e.g. 2025.0.0, 2026.0.0-M1). This project's entry in that file is the + version being released, which names the release/ branch and is + passed to update-project-versions to resolve dependency versions. required: true spring-release-train-version: description: > @@ -41,56 +37,87 @@ inputs: runs: using: composite steps: - - name: Validate version against jenkins-releaser-config + - name: Resolve the project version and confirm it is unreleased + id: version shell: bash env: GH_TOKEN: ${{ inputs.token }} run: | project="${{ inputs.project }}" - project_version="${{ inputs.project-version }}" release_train_version="${{ inputs.spring-cloud-release-train-version }}" - if [[ "$project" == *-commercial ]]; then - base_project="${project%-commercial}" - release_repo="spring-cloud/spring-cloud-release-commercial" - else - base_project="$project" - release_repo="spring-cloud/spring-cloud-release" - fi - - filename="${release_train_version//./_}.properties" - echo "Fetching ${filename} from ${release_repo}@jenkins-releaser-config..." - - content=$(gh api "repos/${release_repo}/contents/${filename}?ref=jenkins-releaser-config" \ - --jq '.content' | tr -d '\n' | base64 -d) - - config_version=$(echo "$content" | \ - grep "^releaser\.fixed-versions\[${base_project}\]=" | \ - cut -d'=' -f2 | tr -d '[:space:]') - - if [[ -z "$config_version" ]]; then - echo "ERROR: '${base_project}' not found in ${filename} on ${release_repo}@jenkins-releaser-config" + # The plain project name: the -commercial suffix selects the repository to act on, + # not the project, and the tag lookups below check both sides. + base_project="${project%-commercial}" + + # Where the config lives, the file name, and the lookup all come from the shared + # module - it owns that choice for every caller, and it is always + # spring-cloud-release-commercial, for OSS releases too. Only the project repository + # checked out below is selected by the -commercial suffix; the properties key is the + # plain project name, which the module strips for us. + # + # GITHUB_ACTION_PATH rather than the workspace so this works for external callers, + # the way dependabot-scan reaches prerelease-rank.js. + script="${GITHUB_ACTION_PATH}/../../scripts/releaser-config.js" + + # rc captured through || rather than read from $? after an `if !`, where $? is the + # result of the negation and always 0 - which would swallow the exit code the script + # uses to say *why* it failed. + rc=0 + config_version=$(node "$script" "$release_train_version" "$project") || rc=$? + if [[ "$rc" -ne 0 ]]; then + case "$rc" in + 3) echo "ERROR: no properties file for release train '${release_train_version}' in" + echo "spring-cloud-release-commercial@jenkins-releaser-config." ;; + 4) echo "ERROR: '${project%-commercial}' is not part of release train '${release_train_version}'." ;; + *) echo "ERROR: could not read the releaser config (exit ${rc})." ;; + esac exit 1 fi - echo "Version in jenkins-releaser-config: ${config_version}" - echo "Version in branch name: ${project_version}" - - if [[ "$config_version" != "$project_version" ]]; then - echo "ERROR: Version mismatch!" - echo " Branch name expects: release/${project_version}" - echo " jenkins-releaser-config: ${config_version}" - echo " Check that spring-cloud-release-train-version is correct." + echo "Release train: ${release_train_version}" + echo "Version to release: ${config_version}" + echo "Release branch: release/${config_version}" + + # Release branches are not deleted after a release, so release/5.0.5 is still there + # long after 5.0.5 shipped. Without this, a run naming an already-released train + # would happily re-stamp that branch and re-dispatch readiness for it - the version + # check this replaced never caught that, because it only compared two inputs that + # agreed with each other. + # + # matching-refs returns an array and is compared exactly: a plain + # git/refs/tags/v5.0.2 lookup also prefix-matches v5.0.20. + tag="v${config_version}" + tag_exists() { + gh api "repos/$1/git/matching-refs/tags/${tag}" --jq '.[].ref' 2>/dev/null \ + | grep -qx "refs/tags/${tag}" + } + + # An OSS release tags the OSS repository and a commercial one tags the commercial + # repository, so both are checked. A repository that does not exist returns nothing, + # which reads as "not released" - correct for a commercial-only project. + released=false + for repo in "spring-cloud/${base_project}" "spring-cloud/${base_project}-commercial"; do + if tag_exists "$repo"; then + echo "ERROR: ${tag} already exists in ${repo}." + released=true + else + echo " ${tag} not found in ${repo}" + fi + done + if [[ "$released" == "true" ]]; then + echo "Aborting: ${config_version} has already been released, so there is nothing to" + echo "make ready. Check that spring-cloud-release-train-version names the right train." exit 1 fi - echo "Version check passed: ${base_project} = ${project_version}" + echo "version=${config_version}" >> "$GITHUB_OUTPUT" - name: Checkout project release branch uses: actions/checkout@v4 with: repository: spring-cloud/${{ inputs.project }} - ref: release/${{ inputs.project-version }} + ref: release/${{ steps.version.outputs.version }} token: ${{ inputs.token }} path: ${{ inputs.project }} @@ -98,7 +125,11 @@ runs: uses: ./.github/actions/update-project-versions with: release-train-version: ${{ inputs.spring-cloud-release-train-version }} - commercial: ${{ endsWith(inputs.project, '-commercial') }} + # Hardcoded true, not the project's flavour. This input does one thing in the action + # - pick which repository the releaser config is fetched from - and that is always + # spring-cloud-release-commercial, matching the version check above. Passing the + # derived value would validate against one file and stamp from another. + commercial: 'true' token: ${{ inputs.token }} directory: ${{ inputs.project }} @@ -121,10 +152,16 @@ runs: echo "release-ci-settings.xml not found — skipping." fi + # A milestone or release candidate stamps -M/-RC versions on purpose, and its + # dependencies are a mixture of milestone, release-candidate and GA versions. Only + # -SNAPSHOT is always wrong, so the milestone half of the check is turned off for a + # pre-release. Derived from the version being released rather than taken as an input: + # the two can never legitimately disagree. - name: Verify no snapshot versions uses: ./.github/actions/verify-no-snapshot-versions with: directory: ${{ inputs.project }} + allow-prerelease: ${{ contains(steps.version.outputs.version, '-M') || contains(steps.version.outputs.version, '-RC') }} - name: Commit and push changes shell: bash @@ -136,7 +173,7 @@ runs: if git diff --cached --quiet; then echo "No changes to commit." else - git commit -m "Release ${{ inputs.project-version }}" + git commit -m "Release ${{ steps.version.outputs.version }}" git push echo "Committed and pushed changes." fi @@ -148,7 +185,7 @@ runs: run: | run_url=$(gh workflow run release-train-ready.yml \ --repo spring-cloud/${{ inputs.project }} \ - --ref release/${{ inputs.project-version }} \ + --ref release/${{ steps.version.outputs.version }} \ --field release-train=${{ inputs.spring-release-train-version }} \ --field release-train-repository=spring-io/release-train) echo "Dispatched workflow run. Waiting for $run_url to complete." @@ -166,7 +203,7 @@ runs: uses: ./.github/actions/update-antora-playbook with: repository: spring-cloud/${{ inputs.project }} - branch: release/${{ inputs.project-version }} + branch: release/${{ steps.version.outputs.version }} operation: remove token: ${{ inputs.token }} diff --git a/.github/actions/update-project-versions/__tests__/index.test.js b/.github/actions/update-project-versions/__tests__/index.test.js index 2aecaa4..5a2af0a 100644 --- a/.github/actions/update-project-versions/__tests__/index.test.js +++ b/.github/actions/update-project-versions/__tests__/index.test.js @@ -14,6 +14,7 @@ const { hasVersionCheckOff, updateGradlePropertiesContent, updateBuildGradleContent, + projectForArtifact, camelToKebab, artifactIdToProjectName, isChildOfRoot, @@ -232,6 +233,45 @@ describe('updateGradlePropertiesContent', () => { }); }); +// ── projectForArtifact ──────────────────────────────────────────────────────── + +describe('projectForArtifact', () => { + const versions = { + 'spring-boot': '4.2.0-M2', + 'spring-cloud-function': '5.1.0-M1', + 'spring-cloud-config': '5.1.0-M1', + }; + + it('resolves an exact project name', () => { + expect(projectForArtifact('spring-cloud-function', versions)).toBe('spring-cloud-function'); + }); + + // Spring Cloud publishes a project's modules at the project's own version. + it('resolves a module to the project that releases it', () => { + expect(projectForArtifact('spring-cloud-function-adapter-azure', versions)) + .toBe('spring-cloud-function'); + expect(projectForArtifact('spring-cloud-config-server', versions)) + .toBe('spring-cloud-config'); + expect(projectForArtifact('spring-boot-starter-web', versions)).toBe('spring-boot'); + }); + + // The boundary is what stops spring-cloud-configuration resolving to spring-cloud-config. + it('only matches on a dash boundary', () => { + expect(projectForArtifact('spring-cloud-configuration', versions)).toBeNull(); + }); + + it('prefers the longest match', () => { + const nested = { 'spring-cloud': 'a', 'spring-cloud-function': 'b' }; + expect(projectForArtifact('spring-cloud-function-adapter-azure', nested)) + .toBe('spring-cloud-function'); + }); + + it('returns null for an artifact no project releases', () => { + expect(projectForArtifact('some-other-lib', versions)).toBeNull(); + expect(projectForArtifact('spring-cloud-starter-function-web', versions)).toBeNull(); + }); +}); + // ── updateBuildGradleContent ────────────────────────────────────────────────── describe('updateBuildGradleContent', () => { @@ -254,6 +294,107 @@ describe('updateBuildGradleContent', () => { expect(updated).toContain(`description = 'My project'`); }); + // spring-cloud-function's Gradle samples declare these in an ext block rather than in + // gradle.properties, so a release used to leave them at whatever they had been pinned at + // - Boot 2.1.0.BUILD-SNAPSHOT in a train releasing against Boot 4. + describe('version properties in an ext block', () => { + const versions = { + 'spring-boot': '4.2.0-M2', + 'spring-cloud-function': '5.1.0-M1', + }; + + it('updates a property inside buildscript { ext { } }', () => { + const content = [ + 'buildscript {', + '\text {', + "\t\tspringBootVersion = '2.1.0.BUILD-SNAPSHOT'", + '\t}', + '}', + ].join('\n'); + const { updated, updatedProperties } = + updateBuildGradleContent(content, '5.1.0-M1', versions); + expect(updated).toContain("springBootVersion = '4.2.0-M2'"); + expect(updatedProperties).toEqual(['springBootVersion: 4.2.0-M2']); + }); + + it('updates a double-quoted property and preserves the quote style', () => { + const content = 'ext {\n\tspringCloudFunctionVersion = "2.0.0.BUILD-SNAPSHOT"\n}'; + const { updated } = updateBuildGradleContent(content, '5.1.0-M1', versions); + expect(updated).toContain('springCloudFunctionVersion = "5.1.0-M1"'); + }); + + it('preserves indentation', () => { + const content = " springBootVersion = '2.1.0.BUILD-SNAPSHOT'"; + const { updated } = updateBuildGradleContent(content, '5.1.0-M1', versions); + expect(updated).toBe(" springBootVersion = '4.2.0-M2'"); + }); + + it('leaves a property that resolves to no project alone', () => { + const content = "ext {\n\tjavaVersion = '11'\n}"; + const { updated, updatedProperties } = + updateBuildGradleContent(content, '5.1.0-M1', versions); + expect(updated).toBe(content); + expect(updatedProperties).toEqual([]); + }); + + it('leaves an unquoted value alone', () => { + const content = 'languageVersion = JavaLanguageVersion.of(17)'; + expect(updateBuildGradleContent(content, '5.1.0-M1', versions).updated).toBe(content); + }); + + it('does not treat the project version line as a property', () => { + const content = "version = '5.0.0'\n"; + const { updated, updatedProperties } = + updateBuildGradleContent(content, '5.1.0-M1', versions); + expect(updated).toBe("version = '5.1.0-M1'\n"); + expect(updatedProperties).toEqual([]); + }); + + it('updates the project version and its properties together', () => { + const content = [ + "version = '5.1.0-INTERNAL-SNAPSHOT'", + 'ext {', + "\tspringCloudFunctionVersion = '2.0.0.BUILD-SNAPSHOT'", + '}', + ].join('\n'); + const { updated } = updateBuildGradleContent(content, '5.1.0-M1', versions); + expect(updated).toContain("version = '5.1.0-M1'"); + expect(updated).toContain("springCloudFunctionVersion = '5.1.0-M1'"); + }); + + it('rewrites an inline dependency coordinate for a released artifact', () => { + const content = + 'implementation "org.springframework.cloud:spring-cloud-function-adapter-azure:4.1.0-SNAPSHOT"'; + const { updated, updatedProperties } = + updateBuildGradleContent(content, '5.1.0-M1', versions); + expect(updated).toContain( + '"org.springframework.cloud:spring-cloud-function-adapter-azure:5.1.0-M1"'); + expect(updatedProperties).toEqual(['spring-cloud-function-adapter-azure: 5.1.0-M1']); + }); + + // Replacing the reference with a literal would break the indirection the build uses. + it('leaves an interpolated coordinate version alone', () => { + const content = + 'mavenBom "org.springframework.cloud:spring-cloud-function-dependencies:${springCloudFunctionVersion}"'; + expect(updateBuildGradleContent(content, '5.1.0-M1', versions).updated).toBe(content); + }); + + it('leaves a non-Spring group alone even when the artifact name matches', () => { + const content = "implementation 'com.example:spring-cloud-function-fork:1.0.0-SNAPSHOT'"; + expect(updateBuildGradleContent(content, '5.1.0-M1', versions).updated).toBe(content); + }); + + it('leaves a coordinate with no version alone', () => { + const content = "implementation 'org.springframework.cloud:spring-cloud-function-context'"; + expect(updateBuildGradleContent(content, '5.1.0-M1', versions).updated).toBe(content); + }); + + it('is a no-op when no versions map is supplied', () => { + const content = "ext {\n\tspringBootVersion = '2.1.0.BUILD-SNAPSHOT'\n}"; + expect(updateBuildGradleContent(content, '5.1.0-M1').updated).toBe(content); + }); + }); + it('returns unchanged content when no version declaration is present', () => { const content = `group = 'org.example'\n`; expect(updateBuildGradleContent(content, '3.1.1').updated).toBe(content); diff --git a/.github/actions/update-project-versions/dist/index.js b/.github/actions/update-project-versions/dist/index.js index bac070d..f534e59 100644 --- a/.github/actions/update-project-versions/dist/index.js +++ b/.github/actions/update-project-versions/dist/index.js @@ -28224,6 +28224,7 @@ module.exports = { /***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { const core = __nccwpck_require__(7484); +const { releaserConfigFileName } = __nccwpck_require__(2805); const { XMLParser } = __nccwpck_require__(9741); const fs = __nccwpck_require__(9896); const path = __nccwpck_require__(6928); @@ -28373,8 +28374,8 @@ async function run() { } // ── build.gradle / build.gradle.kts ──────────────────────────────────── - // Only the project version declaration is updated (version = '...' / version = "..."). - // Dependency versions are managed exclusively via gradle.properties in Spring Cloud. + // The project version declaration, plus any `{prefix}Version` properties declared in an + // ext block rather than in gradle.properties. const buildGradleFiles = [ ...findFiles(directory, 'build.gradle'), ...findFiles(directory, 'build.gradle.kts'), @@ -28382,11 +28383,16 @@ async function run() { if (buildGradleFiles.length > 0) { core.info(`Found ${buildGradleFiles.length} build.gradle file(s)`); for (const file of buildGradleFiles) { - const { changed } = updateBuildGradleVersion(file, projectVersion); + const { changed, updatedProperties } = updateBuildGradleVersion( + file, + projectVersion, + versions + ); if (changed) { - core.info(`Updated ${path.relative(directory, file)}: version`); + core.info(`Updated ${path.relative(directory, file)}: ` + + ['version', ...updatedProperties].join(', ')); } else { - core.info(`No changes to ${path.relative(directory, file)}: version`); + core.info(`No changes to ${path.relative(directory, file)}`); } } } @@ -28409,12 +28415,11 @@ async function run() { * * Exported for unit testing. */ -function releaseTrainVersionToFileName(version) { - // Pre-release qualifiers (-SNAPSHOT, -RC1, -M1, etc.) are lowercase in file names. - return version - .replace(/-([a-zA-Z].*)$/, (_, q) => '-' + q.toLowerCase()) - .replace(/\./g, '_') + '.properties'; -} +// Re-exported under its original name so the tests and the rest of this file are unchanged. +// The rule itself lives in .github/scripts/releaser-config-file.js because six places need +// it - this action, three workflows and two composite actions - and each used to carry its +// own copy. ncc bundles this require into dist/, so the published action stays standalone. +const releaseTrainVersionToFileName = releaserConfigFileName; /** * Builds the raw GitHub URL for the releaser config properties file. @@ -28774,27 +28779,29 @@ function updateGradlePropertiesContent(content, projectVersion, versions) { // ── build.gradle / build.gradle.kts ─────────────────────────────────────── /** - * Updates the project version declaration in a build.gradle or build.gradle.kts file. - * Handles both single-quoted and double-quoted versions: + * Updates the project version declaration and any version properties in a build.gradle + * or build.gradle.kts file. Handles both single-quoted and double-quoted versions: * version = '4.1.0' - * version = "4.1.0" - * - * Only the project-level version line is updated; dependency version properties - * are managed via gradle.properties in Spring Cloud projects. + * springCloudFunctionVersion = "4.1.0" * * Exported for unit testing. * * @param {string} filePath * @param {string} projectVersion + * @param {Record} versions */ -function updateBuildGradleVersion(filePath, projectVersion) { +function updateBuildGradleVersion(filePath, projectVersion, versions) { const content = fs.readFileSync(filePath, 'utf-8'); - const { updated } = updateBuildGradleContent(content, projectVersion); + const { updated, updatedProperties } = updateBuildGradleContent( + content, + projectVersion, + versions + ); const changed = updated !== content; if (changed) { fs.writeFileSync(filePath, updated, 'utf-8'); } - return { changed }; + return { changed, updatedProperties }; } /** @@ -28803,14 +28810,64 @@ function updateBuildGradleVersion(filePath, projectVersion) { * * Exported for unit testing. */ -function updateBuildGradleContent(content, projectVersion) { +function updateBuildGradleContent(content, projectVersion, versions = {}) { // Match: version = '...' or version = "..." at the start of a line (with optional spaces) - const updated = content.replace( + const withProjectVersion = content.replace( /^(version\s*=\s*)(['"])([^'"]+)(['"])/m, (_, prefix, openQuote, _oldVersion, closeQuote) => `${prefix}${openQuote}${projectVersion}${closeQuote}` ); - return { updated }; + + // `{prefix}Version` assignments, resolved exactly as they are in gradle.properties: + // camelCase prefix -> kebab-case project name -> the train's version for it. These live + // in an `ext { }` or `buildscript { ext { } }` block rather than at the start of a line, + // so leading whitespace is part of the match and is preserved. + // + // Spring Cloud projects mostly declare these in gradle.properties, which is why this + // file only ever rewrote the project version. spring-cloud-function's Gradle samples + // declare them here instead, so a release left them at whatever they had been pinned at. + // + // A key that resolves to no project is left alone, which is what keeps `javaVersion` and + // similar build settings out of it. + const updatedProperties = []; + const updated = withProjectVersion.split('\n').map((line) => { + const match = line.match( + /^(\s*)([a-zA-Z][a-zA-Z0-9]*Version)(\s*=\s*)(['"])([^'"]+)(['"])(.*)$/ + ); + if (!match) return line; + + const [, indent, key, separator, openQuote, currentValue, closeQuote, trailing] = match; + const projectName = camelToKebab(key.slice(0, -'Version'.length)); + const targetVersion = versions[projectName]; + if (!targetVersion || currentValue === targetVersion) return line; + + updatedProperties.push(`${key}: ${targetVersion}`); + return `${indent}${key}${separator}${openQuote}${targetVersion}${closeQuote}${trailing}`; + }).join('\n'); + + // Inline dependency coordinates - "group:artifact:version" - for artifacts this train + // releases. Most Spring Cloud builds express these through a property or let the BOM + // manage them, which is why this file never needed it; spring-cloud-function's Azure + // sample pins one directly. + // + // Only org.springframework.* groups, so a third-party artifact that happens to share a + // prefix is never touched, and never when the version is an interpolation - rewriting + // "...:${springCloudFunctionVersion}" would replace the reference with a literal and + // break the very indirection the build is using. + const COORDINATE = /(['"])(org\.springframework\.[a-z0-9.]+):([A-Za-z0-9_.-]+):([^'"]+)\1/g; + const withCoordinates = updated.replace( + COORDINATE, + (whole, quote, groupId, artifactId, version) => { + if (version.includes('$')) return whole; + const projectName = projectForArtifact(artifactId, versions); + const targetVersion = projectName && versions[projectName]; + if (!targetVersion || version === targetVersion) return whole; + updatedProperties.push(`${artifactId}: ${targetVersion}`); + return `${quote}${groupId}:${artifactId}:${targetVersion}${quote}`; + } + ); + + return { updated: withCoordinates, updatedProperties }; } // ── Utilities ────────────────────────────────────────────────────────────── @@ -28861,6 +28918,31 @@ function artifactIdToProjectName(artifactId) { .replace(/-parent$/, ''); } +/** + * Resolves a Maven artifactId to the project in `versions` that releases it: an exact + * match, or the longest project name the artifactId extends on a `-` boundary. + * + * Spring Cloud publishes a project's modules at the project's own version, so + * spring-cloud-function-adapter-azure ships with spring-cloud-function and + * spring-cloud-config-server with spring-cloud-config. The boundary matters: + * spring-cloud-configuration would not resolve to spring-cloud-config. + * + * Longest wins so a project whose name extends another still resolves to itself. + * Returns null when nothing matches, which leaves the coordinate alone. + * + * Exported for unit testing. + */ +function projectForArtifact(artifactId, versions) { + if (Object.prototype.hasOwnProperty.call(versions, artifactId)) return artifactId; + let best = null; + for (const name of Object.keys(versions)) { + if (artifactId.startsWith(`${name}-`) && (best === null || name.length > best.length)) { + best = name; + } + } + return best; +} + /** * Returns true when a child pom's parent is part of this project * (i.e. not an external parent like spring-boot-starter-parent or @@ -28932,6 +29014,7 @@ module.exports = { findFiles, camelToKebab, artifactIdToProjectName, + projectForArtifact, isChildOfRoot, }; @@ -28940,6 +29023,52 @@ if (require.main === require.cache[eval('__filename')]) { } +/***/ }), + +/***/ 2805: +/***/ ((module) => { + +"use strict"; + + +// The name of a jenkins-releaser-config properties file for a release train version. +// +// Six places needed this rule and each carried its own copy, in two languages, under a +// comment saying it had to stay identical to the others. It did not: post-release.yml and +// spring-release-train-project-ready built 2026_0_0-M1.properties while the action that +// actually reads the file resolved 2026_0_0-m1.properties, so a milestone release validated +// one file and stamped from another. That drift was invisible for years because a GA version +// carries no qualifier at all, which is exactly the case every copy agreed on. +// +// Required by the inline node scripts in the workflows, by update-project-versions, and - +// through the CLI at the bottom - by the composite actions that need it from bash. + +// Lower-cases a pre-release qualifier and leaves the numeric part alone, then swaps dots for +// underscores: +// +// 2026.0.0 -> 2026_0_0.properties +// 2026.0.0-M1 -> 2026_0_0-m1.properties +// 2026.0.0-RC2 -> 2026_0_0-rc2.properties +// 2026.0.0-SNAPSHOT -> 2026_0_0-snapshot.properties +// 2026.0.0-INTERNAL-SNAPSHOT -> 2026_0_0-internal-snapshot.properties +// 2025.1.2.1 -> 2025_1_2_1.properties +// +// The qualifier is everything from the first `-` followed by a letter, so the whole of +// -INTERNAL-SNAPSHOT is lower-cased rather than just its first word. +const releaserConfigFileName = version => String(version).trim() + .replace(/-([a-zA-Z].*)$/, (_, q) => '-' + q.toLowerCase()) + .replace(/\./g, '_') + '.properties'; + +module.exports = { releaserConfigFileName }; + +// CLI, so a composite action's bash can call this rather than reimplement it: +// +// file=$(node "$GITHUB_ACTION_PATH/../../scripts/releaser-config-file.js" "$train") +// +// Guarded on require.main so importing the module never runs it. +if (false) {} + + /***/ }), /***/ 2613: diff --git a/.github/actions/update-project-versions/dist/licenses.txt b/.github/actions/update-project-versions/dist/licenses.txt index ba2e3e4..9d4d263 100644 --- a/.github/actions/update-project-versions/dist/licenses.txt +++ b/.github/actions/update-project-versions/dist/licenses.txt @@ -106,6 +106,8 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +spring-cloud-github-actions-scripts + strnum MIT MIT License diff --git a/.github/actions/update-project-versions/src/index.js b/.github/actions/update-project-versions/src/index.js index 16c1bd5..7de0327 100644 --- a/.github/actions/update-project-versions/src/index.js +++ b/.github/actions/update-project-versions/src/index.js @@ -1,4 +1,5 @@ const core = require('@actions/core'); +const { releaserConfigFileName } = require('../../../scripts/releaser-config-file'); const { XMLParser } = require('fast-xml-parser'); const fs = require('fs'); const path = require('path'); @@ -148,8 +149,8 @@ async function run() { } // ── build.gradle / build.gradle.kts ──────────────────────────────────── - // Only the project version declaration is updated (version = '...' / version = "..."). - // Dependency versions are managed exclusively via gradle.properties in Spring Cloud. + // The project version declaration, plus any `{prefix}Version` properties declared in an + // ext block rather than in gradle.properties. const buildGradleFiles = [ ...findFiles(directory, 'build.gradle'), ...findFiles(directory, 'build.gradle.kts'), @@ -157,11 +158,16 @@ async function run() { if (buildGradleFiles.length > 0) { core.info(`Found ${buildGradleFiles.length} build.gradle file(s)`); for (const file of buildGradleFiles) { - const { changed } = updateBuildGradleVersion(file, projectVersion); + const { changed, updatedProperties } = updateBuildGradleVersion( + file, + projectVersion, + versions + ); if (changed) { - core.info(`Updated ${path.relative(directory, file)}: version`); + core.info(`Updated ${path.relative(directory, file)}: ` + + ['version', ...updatedProperties].join(', ')); } else { - core.info(`No changes to ${path.relative(directory, file)}: version`); + core.info(`No changes to ${path.relative(directory, file)}`); } } } @@ -184,12 +190,11 @@ async function run() { * * Exported for unit testing. */ -function releaseTrainVersionToFileName(version) { - // Pre-release qualifiers (-SNAPSHOT, -RC1, -M1, etc.) are lowercase in file names. - return version - .replace(/-([a-zA-Z].*)$/, (_, q) => '-' + q.toLowerCase()) - .replace(/\./g, '_') + '.properties'; -} +// Re-exported under its original name so the tests and the rest of this file are unchanged. +// The rule itself lives in .github/scripts/releaser-config-file.js because six places need +// it - this action, three workflows and two composite actions - and each used to carry its +// own copy. ncc bundles this require into dist/, so the published action stays standalone. +const releaseTrainVersionToFileName = releaserConfigFileName; /** * Builds the raw GitHub URL for the releaser config properties file. @@ -549,27 +554,29 @@ function updateGradlePropertiesContent(content, projectVersion, versions) { // ── build.gradle / build.gradle.kts ─────────────────────────────────────── /** - * Updates the project version declaration in a build.gradle or build.gradle.kts file. - * Handles both single-quoted and double-quoted versions: + * Updates the project version declaration and any version properties in a build.gradle + * or build.gradle.kts file. Handles both single-quoted and double-quoted versions: * version = '4.1.0' - * version = "4.1.0" - * - * Only the project-level version line is updated; dependency version properties - * are managed via gradle.properties in Spring Cloud projects. + * springCloudFunctionVersion = "4.1.0" * * Exported for unit testing. * * @param {string} filePath * @param {string} projectVersion + * @param {Record} versions */ -function updateBuildGradleVersion(filePath, projectVersion) { +function updateBuildGradleVersion(filePath, projectVersion, versions) { const content = fs.readFileSync(filePath, 'utf-8'); - const { updated } = updateBuildGradleContent(content, projectVersion); + const { updated, updatedProperties } = updateBuildGradleContent( + content, + projectVersion, + versions + ); const changed = updated !== content; if (changed) { fs.writeFileSync(filePath, updated, 'utf-8'); } - return { changed }; + return { changed, updatedProperties }; } /** @@ -578,14 +585,64 @@ function updateBuildGradleVersion(filePath, projectVersion) { * * Exported for unit testing. */ -function updateBuildGradleContent(content, projectVersion) { +function updateBuildGradleContent(content, projectVersion, versions = {}) { // Match: version = '...' or version = "..." at the start of a line (with optional spaces) - const updated = content.replace( + const withProjectVersion = content.replace( /^(version\s*=\s*)(['"])([^'"]+)(['"])/m, (_, prefix, openQuote, _oldVersion, closeQuote) => `${prefix}${openQuote}${projectVersion}${closeQuote}` ); - return { updated }; + + // `{prefix}Version` assignments, resolved exactly as they are in gradle.properties: + // camelCase prefix -> kebab-case project name -> the train's version for it. These live + // in an `ext { }` or `buildscript { ext { } }` block rather than at the start of a line, + // so leading whitespace is part of the match and is preserved. + // + // Spring Cloud projects mostly declare these in gradle.properties, which is why this + // file only ever rewrote the project version. spring-cloud-function's Gradle samples + // declare them here instead, so a release left them at whatever they had been pinned at. + // + // A key that resolves to no project is left alone, which is what keeps `javaVersion` and + // similar build settings out of it. + const updatedProperties = []; + const updated = withProjectVersion.split('\n').map((line) => { + const match = line.match( + /^(\s*)([a-zA-Z][a-zA-Z0-9]*Version)(\s*=\s*)(['"])([^'"]+)(['"])(.*)$/ + ); + if (!match) return line; + + const [, indent, key, separator, openQuote, currentValue, closeQuote, trailing] = match; + const projectName = camelToKebab(key.slice(0, -'Version'.length)); + const targetVersion = versions[projectName]; + if (!targetVersion || currentValue === targetVersion) return line; + + updatedProperties.push(`${key}: ${targetVersion}`); + return `${indent}${key}${separator}${openQuote}${targetVersion}${closeQuote}${trailing}`; + }).join('\n'); + + // Inline dependency coordinates - "group:artifact:version" - for artifacts this train + // releases. Most Spring Cloud builds express these through a property or let the BOM + // manage them, which is why this file never needed it; spring-cloud-function's Azure + // sample pins one directly. + // + // Only org.springframework.* groups, so a third-party artifact that happens to share a + // prefix is never touched, and never when the version is an interpolation - rewriting + // "...:${springCloudFunctionVersion}" would replace the reference with a literal and + // break the very indirection the build is using. + const COORDINATE = /(['"])(org\.springframework\.[a-z0-9.]+):([A-Za-z0-9_.-]+):([^'"]+)\1/g; + const withCoordinates = updated.replace( + COORDINATE, + (whole, quote, groupId, artifactId, version) => { + if (version.includes('$')) return whole; + const projectName = projectForArtifact(artifactId, versions); + const targetVersion = projectName && versions[projectName]; + if (!targetVersion || version === targetVersion) return whole; + updatedProperties.push(`${artifactId}: ${targetVersion}`); + return `${quote}${groupId}:${artifactId}:${targetVersion}${quote}`; + } + ); + + return { updated: withCoordinates, updatedProperties }; } // ── Utilities ────────────────────────────────────────────────────────────── @@ -636,6 +693,31 @@ function artifactIdToProjectName(artifactId) { .replace(/-parent$/, ''); } +/** + * Resolves a Maven artifactId to the project in `versions` that releases it: an exact + * match, or the longest project name the artifactId extends on a `-` boundary. + * + * Spring Cloud publishes a project's modules at the project's own version, so + * spring-cloud-function-adapter-azure ships with spring-cloud-function and + * spring-cloud-config-server with spring-cloud-config. The boundary matters: + * spring-cloud-configuration would not resolve to spring-cloud-config. + * + * Longest wins so a project whose name extends another still resolves to itself. + * Returns null when nothing matches, which leaves the coordinate alone. + * + * Exported for unit testing. + */ +function projectForArtifact(artifactId, versions) { + if (Object.prototype.hasOwnProperty.call(versions, artifactId)) return artifactId; + let best = null; + for (const name of Object.keys(versions)) { + if (artifactId.startsWith(`${name}-`) && (best === null || name.length > best.length)) { + best = name; + } + } + return best; +} + /** * Returns true when a child pom's parent is part of this project * (i.e. not an external parent like spring-boot-starter-parent or @@ -707,6 +789,7 @@ module.exports = { findFiles, camelToKebab, artifactIdToProjectName, + projectForArtifact, isChildOfRoot, }; diff --git a/.github/actions/verify-no-snapshot-versions/README.md b/.github/actions/verify-no-snapshot-versions/README.md index 4e40256..4c9e5dd 100644 --- a/.github/actions/verify-no-snapshot-versions/README.md +++ b/.github/actions/verify-no-snapshot-versions/README.md @@ -35,6 +35,7 @@ Any other key has its value checked only when the value is *shaped* like a versi | Input | Description | Required | Default | |-------|-------------|----------|---------| +| `allow-prerelease` | Permit `-M` and `-RC` versions. Set when verifying a milestone or release candidate. `-SNAPSHOT` is rejected either way. | No | `false` | | `directory` | Root directory of the project to verify | No | `.` | | `exclude-patterns` | Newline-separated list of regular expressions. Any file whose absolute path matches one of these patterns is excluded from version checking. | No | See below | @@ -123,11 +124,33 @@ steps: Any version value matching one of these patterns (case-insensitive) is a violation: -| Pattern | Example | -|---------|---------| -| `-SNAPSHOT` | `4.2.0-SNAPSHOT` | -| `-RC` | `3.3.0-RC1`, `3.3.0-RC2` | -| `-M` | `2023.0.0-M1`, `4.2.0-M12` | +| Pattern | Example | Flagged by default | Flagged with `allow-prerelease` | +|---------|---------|--------------------|---------------------------------| +| `-SNAPSHOT` | `4.2.0-SNAPSHOT` | Yes | Yes | +| `-RC` | `3.3.0-RC1`, `3.3.0-RC2` | Yes | No | +| `-M` | `2023.0.0-M1`, `4.2.0-M12` | Yes | No | + +### Milestone and release candidate releases + +A GA release must contain nothing but GA versions, which is the default. + +A milestone or release candidate is different: it stamps `-M`/`-RC` on the project +itself, and it depends on the other projects in the train at *their* pre-release versions +while still depending on GA versions of everything outside it. So a `5.1.0-M1` build +legitimately contains a mixture of `-M`, `-RC` and plain versions, and the default +check would report every one of them. + +`allow-prerelease: true` relaxes exactly that, and nothing else. `-SNAPSHOT` is still a +violation, because a snapshot is a moving target that must never be published in any +release. + +This is a blanket allow rather than a match against the specific version being released — +during the `5.1.0-RC1` release a stale `5.1.0-M1` left somewhere would pass. That is +deliberate: a pre-release train carries a mixture of phases by design, so there is no +single correct version to match against. + +`spring-release-train-project-ready` sets this automatically from the version it is +releasing; callers rarely need to pass it by hand. ## Suppressing Individual Version Checks in pom.xml diff --git a/.github/actions/verify-no-snapshot-versions/__tests__/index.test.js b/.github/actions/verify-no-snapshot-versions/__tests__/index.test.js index 28c6ac4..5a05e98 100644 --- a/.github/actions/verify-no-snapshot-versions/__tests__/index.test.js +++ b/.github/actions/verify-no-snapshot-versions/__tests__/index.test.js @@ -58,6 +58,38 @@ describe('isPreRelease', () => { }); }); +// ─── isPreRelease with allow-prerelease ────────────────────────────────────── +// A milestone or release-candidate build legitimately mixes -M, -RC and GA +// versions, so those are permitted; -SNAPSHOT never is. + +describe('isPreRelease with allowPrerelease', () => { + it('still rejects -SNAPSHOT', () => { + expect(isPreRelease('4.2.0-SNAPSHOT', true)).toBe(true); + }); + + it('still rejects -SNAPSHOT whatever its casing', () => { + expect(isPreRelease('4.2.0-snapshot', true)).toBe(true); + }); + + it('permits a milestone version', () => { + expect(isPreRelease('4.2.0-M1', true)).toBe(false); + }); + + it('permits a release candidate version', () => { + expect(isPreRelease('3.3.0-RC1', true)).toBe(false); + }); + + it('permits a milestone alongside a release candidate and a GA version', () => { + for (const v of ['5.1.0-M1', '5.1.0-RC1', '4.2.3']) { + expect(isPreRelease(v, true)).toBe(false); + } + }); + + it('leaves GA versions alone', () => { + expect(isPreRelease('4.2.0', true)).toBe(false); + }); +}); + // ─── looksLikeVersion ──────────────────────────────────────────────────────── describe('looksLikeVersion', () => { diff --git a/.github/actions/verify-no-snapshot-versions/action.yml b/.github/actions/verify-no-snapshot-versions/action.yml index d09a66a..27eefb5 100644 --- a/.github/actions/verify-no-snapshot-versions/action.yml +++ b/.github/actions/verify-no-snapshot-versions/action.yml @@ -1,8 +1,16 @@ name: 'Verify No Snapshot Versions' -description: 'Verifies that every version in every Maven and Gradle build file in a Spring Cloud project is a release version (no -SNAPSHOT, -RC*, or -M* versions), wherever it is declared' +description: 'Verifies that every version in every Maven and Gradle build file in a Spring Cloud project is a release version (no -SNAPSHOT, and no -RC*/-M* unless allow-prerelease is set), wherever it is declared' author: 'Spring Cloud Team' inputs: + allow-prerelease: + description: > + Permit -M and -RC versions. Set this when verifying a milestone or release + candidate: such a build legitimately carries a mixture of milestone, + release-candidate and GA versions, and the default would reject every one of them. + -SNAPSHOT is rejected either way. + required: false + default: 'false' directory: description: 'Root directory of the project to verify. Defaults to the current working directory.' required: false diff --git a/.github/actions/verify-no-snapshot-versions/dist/index.js b/.github/actions/verify-no-snapshot-versions/dist/index.js index 589b1bd..65deb26 100644 --- a/.github/actions/verify-no-snapshot-versions/dist/index.js +++ b/.github/actions/verify-no-snapshot-versions/dist/index.js @@ -28229,12 +28229,19 @@ const fs = __nccwpck_require__(9896); const path = __nccwpck_require__(6928); /** - * Matches pre-release version suffixes that must not appear in a release build: + * Matches the one suffix that must never appear in any release build, milestone + * and release candidate included: * -SNAPSHOT (e.g. 4.1.0-SNAPSHOT) + */ +const SNAPSHOT_PATTERN = /-SNAPSHOT$/i; + +/** + * Matches the pre-release suffixes that are forbidden in a GA build but expected + * in a milestone or release candidate one: * -RC (e.g. 3.2.0-RC1) * -M (e.g. 2023.0.0-M1) */ -const PRE_RELEASE_PATTERN = /-SNAPSHOT$|-RC\d+$|-M\d+$/i; +const MILESTONE_PATTERN = /-(RC|M)\d+$/i; /** * Matches values that are shaped like a version number: an optional leading `v`, @@ -28254,8 +28261,31 @@ const COORDINATE_ELEMENTS = new Set(['dependency', 'plugin', 'extension']); const CHECK_OFF_ANNOTATION = '@releaser:version-check-off'; -function isPreRelease(version) { - return PRE_RELEASE_PATTERN.test(String(version).trim()); +/** + * Whether milestone and release-candidate versions are tolerated. Set once from the + * action input at the top of run(), and read by isPreRelease below. + * + * Module state rather than a parameter threaded through checkPomFile, walkPomNode, + * checkGradlePropertiesContent and the rest: the flag is a property of the run, not of + * any one file, and passing it down eight signatures would obscure them for no gain. + * isPreRelease still takes an explicit override so the unit tests need no setup. + */ +let allowPrereleaseVersions = false; + +/** + * True when `version` must not appear in the build being verified. + * + * -SNAPSHOT always counts. -M and -RC count only when the run is verifying a GA + * release: a milestone or release-candidate build legitimately carries a mixture of + * milestone, release-candidate and GA versions, and rejecting them would fail every + * pre-release the moment it was stamped. + * + * Exported for unit testing. + */ +function isPreRelease(version, allowPrerelease = allowPrereleaseVersions) { + const value = String(version).trim(); + if (SNAPSHOT_PATTERN.test(value)) return true; + return !allowPrerelease && MILESTONE_PATTERN.test(value); } /** @@ -28285,6 +28315,12 @@ async function run() { try { const directory = path.resolve(core.getInput('directory') || '.'); + allowPrereleaseVersions = core.getBooleanInput('allow-prerelease'); + if (allowPrereleaseVersions) { + core.info('allow-prerelease is set: -M and -RC versions are permitted. ' + + '-SNAPSHOT versions are still rejected.'); + } + if (!fs.existsSync(directory)) { core.setFailed(`Directory not found: ${directory}`); return; @@ -28321,16 +28357,23 @@ async function run() { core.setOutput('violations', JSON.stringify(allViolations)); if (allViolations.length === 0) { - core.info('All versions are release versions. No pre-release versions found.'); + core.info(allowPrereleaseVersions + ? 'No -SNAPSHOT versions found.' + : 'All versions are release versions. No pre-release versions found.'); return; } - core.error(`Found ${allViolations.length} pre-release version(s):`); + const noun = allowPrereleaseVersions ? 'SNAPSHOT' : 'pre-release'; + core.error(`Found ${allViolations.length} ${noun} version(s):`); for (const v of allViolations) { core.error(` ${v.file}: ${v.location} = ${v.version}`); } core.setFailed( - `${allViolations.length} pre-release version(s) found. All dependencies must use release versions.` + `${allViolations.length} ${noun} version(s) found. ` + + (allowPrereleaseVersions + ? 'A milestone or release candidate may depend on -M and -RC versions, ' + + 'but never on a -SNAPSHOT.' + : 'All dependencies must use release versions.') ); } catch (error) { core.setFailed(`Action failed: ${error.message}`); diff --git a/.github/actions/verify-no-snapshot-versions/src/index.js b/.github/actions/verify-no-snapshot-versions/src/index.js index 6ef7e1f..31afbf1 100644 --- a/.github/actions/verify-no-snapshot-versions/src/index.js +++ b/.github/actions/verify-no-snapshot-versions/src/index.js @@ -4,12 +4,19 @@ const fs = require('fs'); const path = require('path'); /** - * Matches pre-release version suffixes that must not appear in a release build: + * Matches the one suffix that must never appear in any release build, milestone + * and release candidate included: * -SNAPSHOT (e.g. 4.1.0-SNAPSHOT) + */ +const SNAPSHOT_PATTERN = /-SNAPSHOT$/i; + +/** + * Matches the pre-release suffixes that are forbidden in a GA build but expected + * in a milestone or release candidate one: * -RC (e.g. 3.2.0-RC1) * -M (e.g. 2023.0.0-M1) */ -const PRE_RELEASE_PATTERN = /-SNAPSHOT$|-RC\d+$|-M\d+$/i; +const MILESTONE_PATTERN = /-(RC|M)\d+$/i; /** * Matches values that are shaped like a version number: an optional leading `v`, @@ -29,8 +36,31 @@ const COORDINATE_ELEMENTS = new Set(['dependency', 'plugin', 'extension']); const CHECK_OFF_ANNOTATION = '@releaser:version-check-off'; -function isPreRelease(version) { - return PRE_RELEASE_PATTERN.test(String(version).trim()); +/** + * Whether milestone and release-candidate versions are tolerated. Set once from the + * action input at the top of run(), and read by isPreRelease below. + * + * Module state rather than a parameter threaded through checkPomFile, walkPomNode, + * checkGradlePropertiesContent and the rest: the flag is a property of the run, not of + * any one file, and passing it down eight signatures would obscure them for no gain. + * isPreRelease still takes an explicit override so the unit tests need no setup. + */ +let allowPrereleaseVersions = false; + +/** + * True when `version` must not appear in the build being verified. + * + * -SNAPSHOT always counts. -M and -RC count only when the run is verifying a GA + * release: a milestone or release-candidate build legitimately carries a mixture of + * milestone, release-candidate and GA versions, and rejecting them would fail every + * pre-release the moment it was stamped. + * + * Exported for unit testing. + */ +function isPreRelease(version, allowPrerelease = allowPrereleaseVersions) { + const value = String(version).trim(); + if (SNAPSHOT_PATTERN.test(value)) return true; + return !allowPrerelease && MILESTONE_PATTERN.test(value); } /** @@ -60,6 +90,12 @@ async function run() { try { const directory = path.resolve(core.getInput('directory') || '.'); + allowPrereleaseVersions = core.getBooleanInput('allow-prerelease'); + if (allowPrereleaseVersions) { + core.info('allow-prerelease is set: -M and -RC versions are permitted. ' + + '-SNAPSHOT versions are still rejected.'); + } + if (!fs.existsSync(directory)) { core.setFailed(`Directory not found: ${directory}`); return; @@ -96,16 +132,23 @@ async function run() { core.setOutput('violations', JSON.stringify(allViolations)); if (allViolations.length === 0) { - core.info('All versions are release versions. No pre-release versions found.'); + core.info(allowPrereleaseVersions + ? 'No -SNAPSHOT versions found.' + : 'All versions are release versions. No pre-release versions found.'); return; } - core.error(`Found ${allViolations.length} pre-release version(s):`); + const noun = allowPrereleaseVersions ? 'SNAPSHOT' : 'pre-release'; + core.error(`Found ${allViolations.length} ${noun} version(s):`); for (const v of allViolations) { core.error(` ${v.file}: ${v.location} = ${v.version}`); } core.setFailed( - `${allViolations.length} pre-release version(s) found. All dependencies must use release versions.` + `${allViolations.length} ${noun} version(s) found. ` + + (allowPrereleaseVersions + ? 'A milestone or release candidate may depend on -M and -RC versions, ' + + 'but never on a -SNAPSHOT.' + : 'All dependencies must use release versions.') ); } catch (error) { core.setFailed(`Action failed: ${error.message}`); diff --git a/.github/scripts/README.md b/.github/scripts/README.md index 1cc2bd9..b308409 100644 --- a/.github/scripts/README.md +++ b/.github/scripts/README.md @@ -25,6 +25,59 @@ run: | So there is nothing to bundle. A `dist/` here would be a byte-identical copy of the source plus one more way for CI to fail. +## `releaser-config.js` + +Where the releaser config lives and how to read it: the repository, the branch, the `gh` +fetch, and the `releaser.fixed-versions[...]` parse. + +**The repository is always `spring-cloud/spring-cloud-release-commercial`, for OSS trains +too**, and deliberately not derived from whether a release is commercial. That branch holds +the config for every train now, and its train files are plain OSS train files — +`2026_0_0-m1.properties` is `spring-cloud-config=5.1.0-M1` and so on. The OSS repository's +copy stopped at 2025.1.3 and disagrees with reality where the two still overlap: it names +`5.0.3` versions of `spring-cloud-task`, `-netflix`, `-zookeeper` and `-vault` that have no +tags and were never released. + +Five places needed this and each carried its own copy. They drifted twice — the properties +file name was upper-cased in two of them, and `spring-release-train-project-ready` was still +reading the OSS repository long after the other four had moved. Callers now keep only their +own error wording, which is the part that genuinely differs. + +It has a CLI so composite actions can use it from bash without a second implementation: + +```bash +node "$GITHUB_ACTION_PATH/../../scripts/releaser-config.js" 2026.0.0-M1 spring-cloud-config +# -> 5.1.0-M1 (exit 3: no such file; exit 4: project not in it) +``` + +## `releaser-config-file.js` + +The train version to properties file name rule — `2026.0.0-M1` → +`2026_0_0-m1.properties`. The qualifier is lower-cased, the numeric part is not. + +Split out from `releaser-config.js` because `update-project-versions` needs the name without +the `gh`-based fetch: it is a `node20` action that resolves the file over +`raw.githubusercontent.com` with a bearer token and has no `gh` available. Also has a CLI, +for the same reason as above. + +## `prerelease-rank.js` + +The `-M` / `-RC` grammar: ranking a milestone or release candidate against the train +it belongs to (`GA > RC > M`, numerically so `M10` beats `M9`), and advancing a train +to its next release. Shared by the Dependabot milestone and project-board resolution and by +`post-release.yml`, which uses it to name the next round of milestones and boards. + +`next(version, promoteTo)` throws on a transition that cannot be meant — a milestone +promoted straight to GA, a release candidate promoted to a release candidate — rather than +returning a version that would go on to name things. + +## `boot-compatibility-range.js` + +The `compatibilityRange` a `spring-cloud` bom mapping carries on start.spring.io. Anchored +on the numeric base of the Spring Boot version in the release's properties file, with the +floor set by the *phase* rather than that Boot version verbatim, so the bound does not churn +on every milestone. + ## `maven-wrapper-properties.js` The rules by which `update-maven-wrapper.yml` edits `maven-wrapper.properties`. It is shared @@ -52,3 +105,9 @@ CI runs them in [test-maven-wrapper-properties.yml](../workflows/test-maven-wrap which also extracts every inline `node` heredoc from `update-maven-wrapper.yml` and syntax-checks it — a syntax error inside a YAML heredoc is otherwise invisible until the workflow runs against a real repository. + +Every module here is covered; `npm test` runs the lot. A workflow that `require`s one of +them **must check the repository out first** — these resolve under +`GITHUB_WORKSPACE`, or under `GITHUB_ACTION_PATH/../../scripts` from inside a composite +action. That is not caught by YAML validation or by the unit tests; it surfaces at runtime +as `Cannot find module`. diff --git a/.github/scripts/__tests__/boot-compatibility-range.test.js b/.github/scripts/__tests__/boot-compatibility-range.test.js new file mode 100644 index 0000000..a7cbbb2 --- /dev/null +++ b/.github/scripts/__tests__/boot-compatibility-range.test.js @@ -0,0 +1,95 @@ +'use strict'; + +const { + bootBase, nextBootMinor, phaseOf, rangeFor, snapshotRangeFor, +} = require('../boot-compatibility-range'); + +describe('bootBase', () => { + it('strips a milestone qualifier', () => { + expect(bootBase('4.2.0-M2')).toBe('4.2.0'); + }); + + it('strips a release candidate qualifier', () => { + expect(bootBase('4.2.0-RC1')).toBe('4.2.0'); + }); + + it('strips a snapshot qualifier', () => { + expect(bootBase('4.2.0-SNAPSHOT')).toBe('4.2.0'); + }); + + it('leaves a GA version alone', () => { + expect(bootBase('4.2.0')).toBe('4.2.0'); + }); + + it('trims surrounding whitespace', () => { + expect(bootBase(' 4.2.0-M2 ')).toBe('4.2.0'); + }); + + it('refuses a version that is not major.minor.patch', () => { + expect(() => bootBase('4.2')).toThrow(/not a \.\./); + expect(() => bootBase('')).toThrow(/not a \.\./); + expect(() => bootBase('main')).toThrow(/not a \.\./); + }); +}); + +describe('nextBootMinor', () => { + it('bumps the minor and zeroes the patch', () => { + expect(nextBootMinor('4.2.0-M2')).toBe('4.3.0'); + expect(nextBootMinor('4.2.5')).toBe('4.3.0'); + }); + + it('carries a double-digit minor rather than rolling the major', () => { + expect(nextBootMinor('4.9.1')).toBe('4.10.0'); + expect(nextBootMinor('4.10.0')).toBe('4.11.0'); + }); +}); + +describe('phaseOf', () => { + it('classifies each phase of a train', () => { + expect(phaseOf('2026.0.0-M1')).toBe('M'); + expect(phaseOf('2026.0.0-RC2')).toBe('RC'); + expect(phaseOf('2026.0.0')).toBeNull(); + }); + + it('refuses a version outside the grammar', () => { + expect(() => phaseOf('2026.0.0-SNAPSHOT')).toThrow(/cannot classify|not a release version/); + }); +}); + +describe('rangeFor', () => { + // The whole point of anchoring on the phase rather than the Boot version: the train is + // built against Boot 4.2.0-M2, but the range still opens at 4.2.0-M1 so the bound does + // not churn on every milestone. + it('anchors the milestone phase at -M1 whatever Boot milestone the train uses', () => { + expect(rangeFor('4.2.0-M2', '2026.0.0-M1')).toBe('[4.2.0-M1,4.2.0-SNAPSHOT)'); + expect(rangeFor('4.2.0-M3', '2026.0.0-M2')).toBe('[4.2.0-M1,4.2.0-SNAPSHOT)'); + }); + + it('moves the floor to -RC1 for release candidates', () => { + expect(rangeFor('4.2.0-RC1', '2026.0.0-RC1')).toBe('[4.2.0-RC1,4.2.0-SNAPSHOT)'); + expect(rangeFor('4.2.0-RC2', '2026.0.0-RC2')).toBe('[4.2.0-RC1,4.2.0-SNAPSHOT)'); + }); + + it('widens to the next Boot minor at GA', () => { + expect(rangeFor('4.2.0', '2026.0.0')).toBe('[4.2.0,4.3.0-M1)'); + }); + + it('walks a whole train', () => { + const boot = '4.2.0-M2'; + expect(rangeFor(boot, '2026.0.0-M1')).toBe('[4.2.0-M1,4.2.0-SNAPSHOT)'); + expect(rangeFor(boot, '2026.0.0-M2')).toBe('[4.2.0-M1,4.2.0-SNAPSHOT)'); + expect(rangeFor(boot, '2026.0.0-RC1')).toBe('[4.2.0-RC1,4.2.0-SNAPSHOT)'); + expect(rangeFor(boot, '2026.0.0')).toBe('[4.2.0,4.3.0-M1)'); + }); +}); + +describe('snapshotRangeFor', () => { + it('runs from the Boot snapshot to the next Boot minor', () => { + expect(snapshotRangeFor('4.2.0-M2')).toBe('[4.2.0-SNAPSHOT,4.3.0-M1)'); + }); + + it('does not depend on which Boot pre-release the train uses', () => { + expect(snapshotRangeFor('4.2.0-RC1')).toBe(snapshotRangeFor('4.2.0-M2')); + expect(snapshotRangeFor('4.2.0')).toBe(snapshotRangeFor('4.2.0-M2')); + }); +}); diff --git a/.github/scripts/__tests__/prerelease-rank.test.js b/.github/scripts/__tests__/prerelease-rank.test.js new file mode 100644 index 0000000..0edd9d7 --- /dev/null +++ b/.github/scripts/__tests__/prerelease-rank.test.js @@ -0,0 +1,138 @@ +'use strict'; + +const { rank, byRankDesc, best, split, isPrerelease, next } = + require('../prerelease-rank'); + +describe('rank', () => { + it('ranks the base itself highest', () => { + expect(rank('2026.0.0', '2026.0.0')).toEqual([2, 0]); + }); + + it('ranks release candidates above milestones', () => { + expect(rank('2026.0.0-RC1', '2026.0.0')).toEqual([1, 1]); + expect(rank('2026.0.0-M1', '2026.0.0')).toEqual([0, 1]); + }); + + it('returns null for titles that do not belong to the base', () => { + expect(rank('2025.1.2', '2026.0.0')).toBeNull(); + expect(rank('2026.0.0-SNAPSHOT', '2026.0.0')).toBeNull(); + expect(rank('2026.0.01', '2026.0.0')).toBeNull(); + expect(rank('2026.0.0-M1-extra', '2026.0.0')).toBeNull(); + }); +}); + +describe('byRankDesc', () => { + it('sorts M10 above M9 rather than below it as a string would', () => { + const entries = [ + { title: '2026.0.0-M9', rank: rank('2026.0.0-M9', '2026.0.0') }, + { title: '2026.0.0-M10', rank: rank('2026.0.0-M10', '2026.0.0') }, + ]; + entries.sort(byRankDesc); + expect(entries[0].title).toBe('2026.0.0-M10'); + }); +}); + +describe('best', () => { + it('picks the furthest-along title belonging to the base', () => { + const titles = ['2026.0.0-M1', '2026.0.0-M2', '2026.0.0-RC1', '2025.1.2']; + expect(best(titles, '2026.0.0')).toBe('2026.0.0-RC1'); + }); + + it('prefers GA over every pre-release', () => { + expect(best(['2026.0.0-RC1', '2026.0.0'], '2026.0.0')).toBe('2026.0.0'); + }); + + it('returns null when nothing belongs to the base', () => { + expect(best(['2025.1.2'], '2026.0.0')).toBeNull(); + }); +}); + +describe('split', () => { + it('pulls a milestone apart', () => { + expect(split('2026.0.0-M1')).toEqual({ base: '2026.0.0', kind: 'M', num: 1 }); + }); + + it('pulls a release candidate apart', () => { + expect(split('2026.0.0-RC12')).toEqual({ base: '2026.0.0', kind: 'RC', num: 12 }); + }); + + it('reports a GA version as having no qualifier', () => { + expect(split('2026.0.0')).toEqual({ base: '2026.0.0', kind: null, num: 0 }); + }); + + it('accepts a four-segment commercial hotfix version', () => { + expect(split('2025.1.2.1')).toEqual({ base: '2025.1.2.1', kind: null, num: 0 }); + }); + + it('trims surrounding whitespace', () => { + expect(split(' 2026.0.0-M1 ')).toEqual({ base: '2026.0.0', kind: 'M', num: 1 }); + }); + + it('returns null for qualifiers outside the grammar', () => { + expect(split('2026.0.0-SNAPSHOT')).toBeNull(); + expect(split('5.0.3-INTERNAL-SNAPSHOT')).toBeNull(); + expect(split('2026.0')).toBeNull(); + expect(split('')).toBeNull(); + }); +}); + +describe('isPrerelease', () => { + it('is true only for -M and -RC', () => { + expect(isPrerelease('2026.0.0-M1')).toBe(true); + expect(isPrerelease('2026.0.0-RC1')).toBe(true); + expect(isPrerelease('2026.0.0')).toBe(false); + expect(isPrerelease('2026.0.0-SNAPSHOT')).toBe(false); + }); +}); + +describe('next', () => { + it('walks a whole train from the first milestone to the release after GA', () => { + expect(next('2026.0.0-M1', 'none')).toBe('2026.0.0-M2'); + expect(next('2026.0.0-M2', 'RC')).toBe('2026.0.0-RC1'); + expect(next('2026.0.0-RC1', 'none')).toBe('2026.0.0-RC2'); + expect(next('2026.0.0-RC2', 'GA')).toBe('2026.0.0'); + expect(next('2026.0.0', 'none')).toBe('2026.0.1'); + }); + + it('increments numerically, so M9 is followed by M10', () => { + expect(next('2026.0.0-M9', 'none')).toBe('2026.0.0-M10'); + expect(next('2026.0.0-RC9', '')).toBe('2026.0.0-RC10'); + }); + + it('treats a missing promote_to as staying in the current phase', () => { + expect(next('2026.0.0-M1')).toBe('2026.0.0-M2'); + expect(next('2026.0.0-M1', undefined)).toBe('2026.0.0-M2'); + expect(next('2026.0.0-M1', '')).toBe('2026.0.0-M2'); + }); + + it('accepts promote_to in any case', () => { + expect(next('2026.0.0-M1', 'rc')).toBe('2026.0.0-RC1'); + expect(next('2026.0.0-RC1', 'ga')).toBe('2026.0.0'); + }); + + it('ignores promote_to for a GA version', () => { + expect(next('2025.1.2', 'GA')).toBe('2025.1.3'); + expect(next('2025.1.2', 'RC')).toBe('2025.1.3'); + }); + + it('bumps the last segment of a four-segment hotfix version', () => { + expect(next('2025.1.2.1', 'none')).toBe('2025.1.2.2'); + }); + + it('refuses to take a milestone straight to GA', () => { + expect(() => next('2026.0.0-M1', 'GA')).toThrow(/cannot be GA/); + }); + + it('refuses to promote a release candidate to a release candidate', () => { + expect(() => next('2026.0.0-RC1', 'RC')).toThrow(/already a release candidate/); + }); + + it('refuses a version it cannot parse', () => { + expect(() => next('2026.0.0-SNAPSHOT', 'none')).toThrow(/not a release version/); + expect(() => next('5.0.3-INTERNAL', 'none')).toThrow(/not a release version/); + }); + + it('refuses an unknown promote_to', () => { + expect(() => next('2026.0.0-M1', 'FINAL')).toThrow(/Unknown promote_to/); + }); +}); diff --git a/.github/scripts/__tests__/releaser-config-file.test.js b/.github/scripts/__tests__/releaser-config-file.test.js new file mode 100644 index 0000000..f18511d --- /dev/null +++ b/.github/scripts/__tests__/releaser-config-file.test.js @@ -0,0 +1,53 @@ +'use strict'; + +const { releaserConfigFileName } = require('../releaser-config-file'); + +describe('releaserConfigFileName', () => { + it('converts a three-part GA version', () => { + expect(releaserConfigFileName('2025.1.0')).toBe('2025_1_0.properties'); + }); + + it('converts a version with a patch number greater than zero', () => { + expect(releaserConfigFileName('2023.0.3')).toBe('2023_0_3.properties'); + }); + + it('converts a version with double-digit segments', () => { + expect(releaserConfigFileName('2024.0.10')).toBe('2024_0_10.properties'); + }); + + it('converts a four-part commercial hotfix version', () => { + expect(releaserConfigFileName('2025.1.2.1')).toBe('2025_1_2_1.properties'); + }); + + // The case every copy of this rule used to disagree on. A milestone release validated + // 2026_0_0-M1.properties and stamped from 2026_0_0-m1.properties. + it('lower-cases a milestone qualifier', () => { + expect(releaserConfigFileName('2026.0.0-M1')).toBe('2026_0_0-m1.properties'); + }); + + it('lower-cases a release candidate qualifier', () => { + expect(releaserConfigFileName('2026.0.0-RC2')).toBe('2026_0_0-rc2.properties'); + }); + + it('lower-cases a snapshot qualifier', () => { + expect(releaserConfigFileName('2026.0.0-SNAPSHOT')).toBe('2026_0_0-snapshot.properties'); + }); + + // The whole qualifier, not just its first word. + it('lower-cases a multi-word internal snapshot qualifier', () => { + expect(releaserConfigFileName('2026.0.0-INTERNAL-SNAPSHOT')) + .toBe('2026_0_0-internal-snapshot.properties'); + }); + + it('leaves an already lower-cased qualifier alone', () => { + expect(releaserConfigFileName('2026.0.0-m1')).toBe('2026_0_0-m1.properties'); + }); + + it('trims surrounding whitespace', () => { + expect(releaserConfigFileName(' 2026.0.0-M1 ')).toBe('2026_0_0-m1.properties'); + }); + + it('never lower-cases the numeric part', () => { + expect(releaserConfigFileName('2026.0.0')).toBe('2026_0_0.properties'); + }); +}); diff --git a/.github/scripts/__tests__/releaser-config.test.js b/.github/scripts/__tests__/releaser-config.test.js new file mode 100644 index 0000000..c2d4f6c --- /dev/null +++ b/.github/scripts/__tests__/releaser-config.test.js @@ -0,0 +1,72 @@ +'use strict'; + +const { + RELEASER_CONFIG_REPO, RELEASER_CONFIG_BRANCH, parseReleaserConfig, versionOf, +} = require('../releaser-config'); + +const SAMPLE = [ + 'releaser.fixed-versions[spring-boot]=4.2.0-M2', + 'releaser.fixed-versions[spring-cloud-build]=5.1.0-M1', + 'releaser.fixed-versions[spring-cloud-config]=5.1.0-M1', + 'releaser.fixed-versions[spring-cloud-release]=2026.0.0-M1', +].join('\n'); + +describe('the config location', () => { + // Deliberately commercial for OSS trains too - the OSS branch stopped at 2025.1.3 and + // disagrees with reality where the two overlap. + it('is the commercial repository', () => { + expect(RELEASER_CONFIG_REPO).toBe('spring-cloud/spring-cloud-release-commercial'); + expect(RELEASER_CONFIG_BRANCH).toBe('jenkins-releaser-config'); + }); +}); + +describe('parseReleaserConfig', () => { + it('reads every fixed-versions entry, in file order', () => { + expect(parseReleaserConfig(SAMPLE)).toEqual([ + { key: 'spring-boot', version: '4.2.0-M2' }, + { key: 'spring-cloud-build', version: '5.1.0-M1' }, + { key: 'spring-cloud-config', version: '5.1.0-M1' }, + { key: 'spring-cloud-release', version: '2026.0.0-M1' }, + ]); + }); + + it('trims whitespace around the key and the version', () => { + expect(parseReleaserConfig('releaser.fixed-versions[ spring-boot ]= 4.2.0 ')) + .toEqual([{ key: 'spring-boot', version: '4.2.0' }]); + }); + + it('ignores lines that are not fixed-versions entries', () => { + const withNoise = ['# a comment', '', 'other.key=value', SAMPLE].join('\n'); + expect(parseReleaserConfig(withNoise)).toHaveLength(4); + }); + + it('survives a file with no trailing newline', () => { + expect(parseReleaserConfig(SAMPLE.trimEnd())).toHaveLength(4); + }); + + it('returns an empty list for content with no entries', () => { + expect(parseReleaserConfig('# nothing here')).toEqual([]); + expect(parseReleaserConfig('')).toEqual([]); + }); +}); + +describe('versionOf', () => { + const entries = parseReleaserConfig(SAMPLE); + + it('finds a project', () => { + expect(versionOf(entries, 'spring-cloud-config')).toBe('5.1.0-M1'); + }); + + // The properties key is always the plain name, whichever repository the release is for. + it('strips a -commercial suffix before looking up', () => { + expect(versionOf(entries, 'spring-cloud-config-commercial')).toBe('5.1.0-M1'); + }); + + it('returns null for a project that is not in the file', () => { + expect(versionOf(entries, 'spring-cloud-nonesuch')).toBeNull(); + }); + + it('does not match on a prefix', () => { + expect(versionOf(entries, 'spring-cloud')).toBeNull(); + }); +}); diff --git a/.github/scripts/boot-compatibility-range.js b/.github/scripts/boot-compatibility-range.js new file mode 100644 index 0000000..40b7660 --- /dev/null +++ b/.github/scripts/boot-compatibility-range.js @@ -0,0 +1,71 @@ +'use strict'; + +// The compatibilityRange a spring-cloud bom mapping on start.spring.io carries: the range +// of Spring Boot versions a given Spring Cloud release supports. +// +// A train needs two mappings, added when its first milestone ships and then carried +// through the whole progression: +// +// [4.2.0-M1,4.2.0-SNAPSHOT) 2026.0.0-M1 the release being offered +// [4.2.0-SNAPSHOT,4.3.0-M1) 2026.0.0-SNAPSHOT the train's snapshot line +// +// Both are anchored on the Spring Boot version in the release's properties file, but only +// on its numeric base. The floor of the release range is the *phase* floor rather than +// that Boot version verbatim - 4.2.0-M1 for the whole milestone phase even when the train +// is built against Boot 4.2.0-M2 - so the range does not churn on every milestone and a +// user on an earlier Boot milestone is still offered the train. + +const { split } = require('./prerelease-rank'); + +// The numeric part of a Boot version: 4.2.0-M2 -> 4.2.0, 4.2.0-SNAPSHOT -> 4.2.0, +// 4.2.0 -> 4.2.0. Throws rather than guessing, because every range below is built from +// this and a wrong one would be published to start.spring.io. +const bootBase = version => { + const base = String(version).trim().replace(/-[A-Za-z].*$/, ''); + if (!/^\d+\.\d+\.\d+$/.test(base)) { + throw new Error( + `'${version}' is not a .. Spring Boot version, so no ` + + 'compatibility range can be derived from it.'); + } + return base; +}; + +// The next Boot minor line, patch zeroed: 4.2.0-M2 -> 4.3.0, 4.9.1 -> 4.10.0. The major is +// never rolled - Boot going 4.x to 5.0 is not something to infer from arithmetic, and the +// upper bound only has to be a version beyond this line. +const nextBootMinor = version => { + const [major, minor] = bootBase(version).split('.').map(Number); + return `${major}.${minor + 1}.0`; +}; + +// Which phase of the train a Spring Cloud version is in. Shared grammar with the +// milestone/board resolution rather than a second copy of the -M/-RC regex. +const phaseOf = releaseVersion => { + const s = split(releaseVersion); + if (!s) { + throw new Error(`'${releaseVersion}' is not a release version this can classify.`); + } + return s.kind; // 'M' | 'RC' | null +}; + +// The range for the entry carrying the release itself. +// +// milestones -> [-M1,-SNAPSHOT) floor is the phase, not the Boot version +// candidates -> [-RC1,-SNAPSHOT) +// GA -> [,-M1) widened, since GA serves the whole line +// +// The pre-release forms stop at -SNAPSHOT so the snapshot entry below takes over +// from there; the GA form has no such neighbour to defer to. +const rangeFor = (bootVersion, releaseVersion) => { + const base = bootBase(bootVersion); + const phase = phaseOf(releaseVersion); + if (phase === null) return `[${base},${nextBootMinor(bootVersion)}-M1)`; + return `[${base}-${phase}1,${base}-SNAPSHOT)`; +}; + +// The range for the entry carrying the train's snapshot line. Written once, when the first +// milestone adds it, and never rewritten afterwards. +const snapshotRangeFor = bootVersion => + `[${bootBase(bootVersion)}-SNAPSHOT,${nextBootMinor(bootVersion)}-M1)`; + +module.exports = { bootBase, nextBootMinor, phaseOf, rangeFor, snapshotRangeFor }; diff --git a/.github/scripts/prerelease-rank.js b/.github/scripts/prerelease-rank.js index 85483f7..c609a16 100644 --- a/.github/scripts/prerelease-rank.js +++ b/.github/scripts/prerelease-rank.js @@ -44,4 +44,83 @@ const best = (titles, base) => { return candidates[0].title; }; -module.exports = { rank, byRankDesc, best }; +// ── advancing a train ──────────────────────────────────────────────────────────────── +// The progression a train walks is M1 -> M2 -> ... -> RC1 -> RC2 -> ... -> GA, and only +// half of it is derivable: the step within a qualifier is arithmetic, but the step from +// milestones to release candidates, and from release candidates to GA, is a decision +// somebody makes. post-release.yml takes that decision as its `promote_to` input and +// hands it here. + +// Pulls a version apart. `kind` is null for a GA version, in which case `num` is 0. +// 2026.0.0-M1 -> { base: '2026.0.0', kind: 'M', num: 1 } +// 2026.0.0 -> { base: '2026.0.0', kind: null, num: 0 } +// Returns null for anything that is neither - a -SNAPSHOT, a -INTERNAL-SNAPSHOT, or a +// qualifier this grammar does not know - so callers can reject rather than guess. +const split = version => { + const v = String(version).trim(); + const m = v.match(/^(\d+(?:\.\d+){2,3})(?:-(M|RC)(\d+))?$/); + if (!m) return null; + return { base: m[1], kind: m[2] || null, num: m[2] ? Number(m[3]) : 0 }; +}; + +const isPrerelease = version => { + const s = split(version); + return !!s && s.kind !== null; +}; + +// The version that follows `version`, given the caller's intent. +// +// `promoteTo` is 'RC', 'GA', or anything falsy/'none' for "stay in the current phase". +// A GA version ignores it entirely and bumps its last segment, which is what every +// release before this function existed already did. +// +// Throws on a transition that cannot be meant: a milestone cannot become GA without +// passing through a release candidate, and neither phase can be promoted to itself. +// Throwing rather than returning null is deliberate - the caller is about to name +// milestones and project boards after this value, and a silent wrong answer is far more +// expensive than a failed run. +const next = (version, promoteTo) => { + const s = split(version); + if (!s) { + throw new Error( + `'${version}' is not a release version this can advance. Expected 3 or 4 numeric ` + + 'segments with an optional -M or -RC qualifier.'); + } + + const promote = (promoteTo || 'none').toString().trim().toUpperCase(); + if (!['NONE', 'RC', 'GA', ''].includes(promote)) { + throw new Error(`Unknown promote_to '${promoteTo}'. Expected none, RC or GA.`); + } + + // GA releases have no phase to promote out of; they walk the patch line as they always + // have. The input is ignored rather than rejected so that a promote_to left set from a + // previous run cannot fail an ordinary release. + if (s.kind === null) { + const parts = s.base.split('.'); + parts[parts.length - 1] = String(Number(parts[parts.length - 1]) + 1); + return parts.join('.'); + } + + if (promote === 'RC') { + if (s.kind === 'RC') { + throw new Error( + `${version} is already a release candidate - promote_to=RC has nothing to do. ` + + 'Leave promote_to unset to get the next RC, or set it to GA.'); + } + return `${s.base}-RC1`; + } + + if (promote === 'GA') { + if (s.kind === 'M') { + throw new Error( + `${version} is a milestone, so the next release cannot be GA. A train goes ` + + 'M -> RC -> GA; set promote_to=RC first.'); + } + return s.base; + } + + // Same phase, next number. + return `${s.base}-${s.kind}${s.num + 1}`; +}; + +module.exports = { rank, byRankDesc, best, split, isPrerelease, next }; diff --git a/.github/scripts/releaser-config-file.js b/.github/scripts/releaser-config-file.js new file mode 100644 index 0000000..91f702e --- /dev/null +++ b/.github/scripts/releaser-config-file.js @@ -0,0 +1,45 @@ +'use strict'; + +// The name of a jenkins-releaser-config properties file for a release train version. +// +// Six places needed this rule and each carried its own copy, in two languages, under a +// comment saying it had to stay identical to the others. It did not: post-release.yml and +// spring-release-train-project-ready built 2026_0_0-M1.properties while the action that +// actually reads the file resolved 2026_0_0-m1.properties, so a milestone release validated +// one file and stamped from another. That drift was invisible for years because a GA version +// carries no qualifier at all, which is exactly the case every copy agreed on. +// +// Required by the inline node scripts in the workflows, by update-project-versions, and - +// through the CLI at the bottom - by the composite actions that need it from bash. + +// Lower-cases a pre-release qualifier and leaves the numeric part alone, then swaps dots for +// underscores: +// +// 2026.0.0 -> 2026_0_0.properties +// 2026.0.0-M1 -> 2026_0_0-m1.properties +// 2026.0.0-RC2 -> 2026_0_0-rc2.properties +// 2026.0.0-SNAPSHOT -> 2026_0_0-snapshot.properties +// 2026.0.0-INTERNAL-SNAPSHOT -> 2026_0_0-internal-snapshot.properties +// 2025.1.2.1 -> 2025_1_2_1.properties +// +// The qualifier is everything from the first `-` followed by a letter, so the whole of +// -INTERNAL-SNAPSHOT is lower-cased rather than just its first word. +const releaserConfigFileName = version => String(version).trim() + .replace(/-([a-zA-Z].*)$/, (_, q) => '-' + q.toLowerCase()) + .replace(/\./g, '_') + '.properties'; + +module.exports = { releaserConfigFileName }; + +// CLI, so a composite action's bash can call this rather than reimplement it: +// +// file=$(node "$GITHUB_ACTION_PATH/../../scripts/releaser-config-file.js" "$train") +// +// Guarded on require.main so importing the module never runs it. +if (require.main === module) { + const version = process.argv[2]; + if (!version) { + process.stderr.write('usage: releaser-config-file.js \n'); + process.exit(2); + } + process.stdout.write(releaserConfigFileName(version) + '\n'); +} diff --git a/.github/scripts/releaser-config.js b/.github/scripts/releaser-config.js new file mode 100644 index 0000000..9692fb1 --- /dev/null +++ b/.github/scripts/releaser-config.js @@ -0,0 +1,130 @@ +'use strict'; + +// Where the releaser config lives, and how to read it. +// +// Five places needed this and each carried its own copy: the repository name, the branch, +// the gh fetch, the base64 decode and the `releaser.fixed-versions[...]` parse. The copies +// drifted twice - spring-release-train-project-ready was still reading the OSS repository +// long after the other four moved, and the properties file name was upper-cased in two of +// them - so the rule lives here now and the callers keep only their own error wording. + +const { execFileSync } = require('child_process'); +const { releaserConfigFileName } = require('./releaser-config-file'); + +// Always spring-cloud-release-commercial, for OSS trains too, and deliberately not derived +// from whether a release is commercial: that repository holds the releaser config for every +// train now, and its train files are plain OSS train files - 2026_0_0-m1.properties is +// spring-cloud-config=5.1.0-M1 and so on, with no commercial-only versions in it. +// +// The OSS repository's copy of this branch stopped at 2025.1.3 and disagrees with reality +// where the two still overlap: it names 5.0.3 versions of spring-cloud-task, -netflix, +// -zookeeper and -vault that have no tags and were never released. +const RELEASER_CONFIG_REPO = 'spring-cloud/spring-cloud-release-commercial'; +const RELEASER_CONFIG_BRANCH = 'jenkins-releaser-config'; + +// Exported as well as used here: post-release rewrites a properties file line by line to +// build the next one, so it needs the pattern itself rather than the parsed result. No /g +// flag, so sharing one instance carries no lastIndex state between callers. +const ENTRY_RE = /^releaser\.fixed-versions\[([^\]]+)\]=(.+)$/; + +// The `releaser.fixed-versions[...]` lines of a properties file, in file order. Anything +// else in the file is ignored rather than rejected - these files have carried comments and +// other keys before. +const parseReleaserConfig = content => { + const entries = []; + for (const line of String(content).split('\n')) { + const m = line.match(ENTRY_RE); + if (m) entries.push({ key: m[1].trim(), version: m[2].trim() }); + } + return entries; +}; + +// One project's version, or null. The key is always the plain project name, so a +// -commercial suffix is stripped before looking it up. +const versionOf = (entries, project) => { + const key = String(project).replace(/-commercial$/, ''); + const hit = entries.find(e => e.key === key); + return hit ? hit.version : null; +}; + +// Reads the properties file for a train version and returns it parsed. +// +// Throws rather than exiting, so each caller can add the guidance that makes sense where it +// is - "post-release writes the -snapshot file", "this workflow does not create one", and +// so on. `err.reason` is 'not-found' when the file could not be read and 'empty' when it +// held no entries, for callers that want to tell those apart. +const fetchReleaserConfig = (trainVersion, { token } = {}) => { + const file = releaserConfigFileName(trainVersion); + let content; + try { + const b64 = execFileSync('gh', ['api', + `repos/${RELEASER_CONFIG_REPO}/contents/${file}?ref=${RELEASER_CONFIG_BRANCH}`, + '--jq', '.content'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + env: token ? { ...process.env, GH_TOKEN: token } : process.env, + }); + content = Buffer.from(b64.replace(/\s/g, ''), 'base64').toString('utf8'); + } catch (err) { + const e = new Error( + `could not read ${file} from ${RELEASER_CONFIG_REPO}@${RELEASER_CONFIG_BRANCH}`); + e.reason = 'not-found'; + e.file = file; + throw e; + } + + const entries = parseReleaserConfig(content); + if (!entries.length) { + const e = new Error(`${file} contains no releaser.fixed-versions[...] entries`); + e.reason = 'empty'; + e.file = file; + throw e; + } + return { file, content, entries }; +}; + +module.exports = { + RELEASER_CONFIG_REPO, + RELEASER_CONFIG_BRANCH, + ENTRY_RE, + parseReleaserConfig, + versionOf, + fetchReleaserConfig, +}; + +// CLI, so a composite action's bash can read the config without a second implementation: +// +// node releaser-config.js -> key=version per line +// node releaser-config.js -> that project's version alone +// +// Progress goes to stderr so stdout is only ever the value being asked for. Exit 3 means +// the file could not be read, 4 that the project is not in it - distinct so a caller can +// say something useful about each. +if (require.main === module) { + const [trainVersion, project] = process.argv.slice(2); + if (!trainVersion) { + process.stderr.write('usage: releaser-config.js [project]\n'); + process.exit(2); + } + let config; + try { + process.stderr.write( + `Reading ${releaserConfigFileName(trainVersion)} from ` + + `${RELEASER_CONFIG_REPO}@${RELEASER_CONFIG_BRANCH}...\n`); + config = fetchReleaserConfig(trainVersion); + } catch (err) { + process.stderr.write(`${err.message}\n`); + process.exit(3); + } + if (!project) { + for (const e of config.entries) process.stdout.write(`${e.key}=${e.version}\n`); + process.exit(0); + } + const version = versionOf(config.entries, project); + if (!version) { + process.stderr.write( + `'${project.replace(/-commercial$/, '')}' is not in ${config.file}\n`); + process.exit(4); + } + process.stdout.write(version + '\n'); +} diff --git a/.github/workflows/README-create-hotfix-branch.md b/.github/workflows/README-create-hotfix-branch.md index 7a7e5e2..fd0dc76 100644 --- a/.github/workflows/README-create-hotfix-branch.md +++ b/.github/workflows/README-create-hotfix-branch.md @@ -9,7 +9,7 @@ Creates a commercial hotfix release branch directly from an OSS tag, applies all 3. **Creates a milestone** — creates a milestone in the commercial repo for the hotfix version if one does not already exist. 4. **Stamps the project version** — updates the project version in `pom.xml`, `gradle.properties`, and `build.gradle` files to `.1-SNAPSHOT` (e.g. `5.0.1` → `5.0.1.1-SNAPSHOT`). Optionally updates dependency versions at the same time. 5. **Ensures required workflows** — checks that `release-train-join.yml` and `release-train-ready.yml` are present on the new branch. If either is missing, runs the workflow generator for that single branch to create them (see [Workflow generator SHA](#workflow-generator-sha)). -6. **Triggers release-train-join** — dispatches `release-train-join.yml` in the commercial repo and waits for it to complete. This step is skipped when `trigger_release_train_join` is set to `false`; the branch is still fully created and initialised. +6. **Triggers release-train-join** — dispatches `release-train-join.yml` in the commercial repo and waits for it to complete. This step is skipped when `spring_release_train` is left empty; the branch is still fully created and initialised. 7. **Triggers CI** — squashes all `[skip actions]` initialisation commits into a single root commit and force pushes it (without `[skip actions]`) to start CI now that the branch is fully initialised. ## Inputs @@ -18,12 +18,11 @@ Creates a commercial hotfix release branch directly from an OSS tag, applies all |------|----------|---------|-------------| | `oss_repo` | yes | — | OSS repository name in the `spring-cloud` org (e.g. `spring-cloud-stream`) | | `oss_tag` | yes | — | Tag in the OSS repository to branch from (e.g. `v5.0.1`) | -| `spring_release_train` | yes | — | Spring release train this hotfix belongs to (e.g. `2026.1`). Passed to `release-train-join`. | +| `spring_release_train` | no | — | Spring release train this hotfix belongs to (e.g. `2026.1`). Passed to `release-train-join`, and supplying it is what triggers the join — leave it empty to prepare the branch without joining. | | `project_version` | no | `.1-SNAPSHOT` | Override the auto-computed hotfix project version | | `release_train_version` | no | — | Release train version (e.g. `2025.1.2` or `2025.1.2.1-snapshot`). When supplied, all dependency version properties are updated from the Spring Cloud release train. Mutually exclusive with `versions`. | | `versions` | no | — | JSON map of dependency versions to apply directly (e.g. `{"spring-boot":"3.3.0","spring-cloud-commons":"4.1.1"}`). Mutually exclusive with `release_train_version`. | | `sha` | no | Triggering commit | Commit SHA of this repo to copy release-train action files from when the workflow generator runs. See [Workflow generator SHA](#workflow-generator-sha). | -| `trigger_release_train_join` | no | `true` | Whether to dispatch `release-train-join.yml` in the commercial repo after the branch is prepared. Set to `false` to create and initialise the branch without joining the release train. | When called as a reusable workflow (`workflow_call`), a `token` secret can also be supplied; if omitted the `GH_ACTIONS_REPO_TOKEN` organisation secret is used. @@ -114,11 +113,12 @@ Create and initialise the branch but opt out of joining the release train: ```bash gh workflow run create-hotfix-release-branch.yml \ -f oss_repo=spring-cloud-foo \ - -f oss_tag=v5.0.1 \ - -f spring_release_train=2026.1 \ - -f trigger_release_train_join=false + -f oss_tag=v5.0.1 ``` +Omitting `spring_release_train` is what opts out: everything else runs, and the branch is +created and initialised as usual. + ### As a reusable workflow ```yaml @@ -130,7 +130,6 @@ jobs: oss_tag: v5.0.1 spring_release_train: '2026.1' release_train_version: '2025.1.2' - trigger_release_train_join: true secrets: token: ${{ secrets.GH_ACTIONS_REPO_TOKEN }} ``` diff --git a/.github/workflows/README-post-release.md b/.github/workflows/README-post-release.md index 5de1c11..b29c350 100644 --- a/.github/workflows/README-post-release.md +++ b/.github/workflows/README-post-release.md @@ -96,7 +96,8 @@ Post Release - 2025.1.2 [spring-cloud-config,spring-cloud-build] - Dry Run | Input | Description | Required | Type | |-------|-------------|----------|------| -| `release_version` | The release train version that just shipped, e.g. `2025.1.2`, or `2025.1.2.1` for a commercial hotfix. Must be a plain numeric version with 3 or 4 segments. | Yes | string | +| `release_version` | The release train version that just shipped, e.g. `2025.1.2`, `2026.0.0-M1` or `2026.0.0-RC1`, or `2025.1.2.1` for a commercial hotfix. Three or four numeric segments, optionally with an `-M` or `-RC` qualifier. | Yes | string | +| `promote_to` | Where the train goes next. `none` stays in the current phase (`M1` → `M2`, `RC1` → `RC2`); `RC` moves a milestone train to `RC1`; `GA` moves a release candidate train to its final version. Ignored for GA and hotfix releases, which always bump the last segment. See [Milestone and release candidate releases](#milestone-and-release-candidate-releases). | No | choice (default: `none`) | | `commercial` | Was this a commercial release? **Ignored when `projects` is supplied.** | No | boolean (default: `false`) | | `projects` | Comma-separated project names, `-commercial` suffix included where applicable. Empty processes every project in the properties file. See [The projects filter](#the-projects-filter). | No | string | | `skip_close_milestones` | Leave the release milestones open. Nothing else changes — the releases are still published, the next round of milestones is still opened, and the merge back still runs. Use it when issues are still being moved between milestones, then re-run with it unchecked (closing a milestone is idempotent, and everything else is a no-op the second time). | No | boolean (default: `false`) | @@ -185,6 +186,107 @@ So the two conventions coexist without overlapping: Note this differs from [ci-status-report](README-ci-status-report.md) and [rollout-deploy-docs](README-rollout-deploy-docs.md), which pair bare project names with a separate `repo_type` input. Here the suffix carries the type, so there is no `repo_type`. +## Milestone and release candidate releases + +A train ships `2026.0.0-M1`, then `-M2`, then `-RC1`, and only finally `2026.0.0`. This +workflow runs after every one of them, but a pre-release is not a small GA release — one +thing is fundamentally different, and most of the special-casing follows from it: + +> **The train does not advance during a pre-release cycle.** `5.1.x` stays on +> `5.1.0-SNAPSHOT` from M1 all the way to GA. + +So on a pre-release run: + +| Step | GA release | Milestone / RC release | +|------|-----------|------------------------| +| Verify tags | runs | runs | +| Next snapshot properties file | written | **skipped** — the train has not moved, so there is no new file | +| New milestones | `5.1.1` | `5.1.0-M2` | +| Merge back | runs | **runs** | +| Version bump on the maintenance branch | pushed | **skipped** — the branch is already on the right snapshot | +| Close milestone, publish release | runs | runs, flagged `prerelease` and **not** marked *Latest* | +| Website PR | runs | runs, with milestone wording and a `PRERELEASE` entry — see below | +| start.spring.io PR | runs | runs, if the bom declares a milestone repository | +| Release board | rolls over | rolls over | + +### Naming the next release + +`M1` → `M2` and `RC1` → `RC2` are arithmetic, so they are derived. Moving from milestones +to release candidates, and from release candidates to GA, is a decision somebody makes — +that is what `promote_to` is for: + +| `release_version` | `promote_to` | Next | +|-------------------|--------------|------| +| `2026.0.0-M1` | `none` | `2026.0.0-M2` | +| `2026.0.0-M2` | `RC` | `2026.0.0-RC1` | +| `2026.0.0-RC1` | `none` | `2026.0.0-RC2` | +| `2026.0.0-RC2` | `GA` | `2026.0.0` | +| `2025.1.2` | ignored | `2025.1.3` | + +A transition that cannot be meant fails the run rather than producing a version that would +go on to name milestones and project boards: a milestone cannot be promoted straight to GA +(set `promote_to=RC` first), and a release candidate cannot be promoted to a release +candidate. The rules live in +[`.github/scripts/prerelease-rank.js`](../scripts/prerelease-rank.js), shared with the +Dependabot triage that resolves milestones and boards by the same grammar. + +### Where the GA release picks the train back up + +The GA run is an ordinary run: `next_snapshot_config` writes +`2026_0_1-snapshot.properties`, the maintenance branch is bumped to `2026.0.1-SNAPSHOT`, +and the site entry that has been carrying the pre-releases is promoted in place to +`GENERAL_AVAILABILITY`. Note that `promote_to=GA` on the **RC2** run only names the *next* +release; it is the run with `release_version: 2026.0.0` that actually moves the train on. + +### One `documentation.json` entry per line, promoted in place + +The site carries exactly one entry for a line while it is in pre-release, rewritten at each +step rather than accumulating: + +| Release | The `5.1` entry afterwards | +|---------|----------------------------| +| `5.1.0-M1` | created — `PRERELEASE`, `5.1.0-M1`, `ref: .../reference/5.1/` | +| `5.1.0-M2` | rewritten — `PRERELEASE`, `5.1.0-M2` | +| `5.1.0-RC1` | rewritten — `PRERELEASE`, `5.1.0-RC1` | +| `5.1.0` | rewritten — `GENERAL_AVAILABILITY`, `5.1.0`, and `current` moves onto it | + +The first one is cloned from **the same line's `SNAPSHOT` entry**, not from the previous +line's GA entry. On this site a pre-release entry's `ref` is keyed to the line rather than +the version — compare spring-data-jpa: + +``` +PRERELEASE 4.2.0-M1 ref .../reference/4.2/ +SNAPSHOT 4.2.0-SNAPSHOT ref .../reference/4.2-SNAPSHOT/ +``` + +so turning the snapshot entry into the pre-release entry is one substitution, +`-SNAPSHOT` → ``. Cloning the previous line's GA entry would leave the `ref` on +*that* line: substituting `5.0.5` → `5.1.0-M1` never touches a ref reading +`.../reference/5.0/`, and the new milestone would quietly point at the old line's docs. +That path still exists as a fallback for a line with no snapshot entry, and it substitutes +the line token as well as the version. + +The `SNAPSHOT` entry itself is left alone during the pre-release cycle — `main` has not +moved — and `current` stays `false` until the GA release, which is the first time the line +is what the site should point people at. + +### Releases are not marked Latest + +A pre-release is published with `prerelease: true` and `make_latest: "false"`. Without the +second of those, GitHub promotes the newest release by date, so publishing `5.1.0-M1` would +put it above the current 5.0.x GA on every project page and in the API that downstream +tooling reads to find the current version. + +### The version gate + +`spring-release-train-project-ready` runs +[`verify-no-snapshot-versions`](../actions/verify-no-snapshot-versions/README.md) after +stamping the release versions. That action rejects `-M` and `-RC` by default, which +would fail every pre-release the moment it was stamped, so it is passed +`allow-prerelease` when the version being released is one. `-SNAPSHOT` is still rejected. + +--- + ## Hotfix releases A **4-segment `release_version`** (e.g. `2025.1.2.1`) is treated as a commercial hotfix and **runs only steps 1, 2, 6 and 7** — verify tags, close the milestone, create the release, document the versions on the commercial site. Steps 3, 4 and 5 are skipped: there is no next snapshot train for a hotfix, no new milestone, no version bump, no Dependabot pass, and **no merge-back, because a hotfix has no branch to merge back into** (the `release/` branch is itself the hotfix line). @@ -425,15 +527,42 @@ The version lives in `start-site/src/main/resources/application.yml`, in one map version: 2025.1.2 ``` -The mapping already on the released train's `major.minor` line is bumped, and nothing else — the `compatibilityRange` is untouched. +A train gets **two** mappings, added the first time it ships and then carried through the whole progression: + +```yaml + - compatibilityRange: "[4.2.0-M1,4.2.0-SNAPSHOT)" + version: 2026.0.0-M1 + - compatibilityRange: "[4.2.0-SNAPSHOT,4.3.0-M1)" + version: 2026.0.0-SNAPSHOT + repositories: + - spring-snapshots +``` + +Both ranges are anchored on the Spring Boot version in the release's properties file — but only on its numeric **base**. Spring Cloud 2026.0.0-M1 is built against Boot `4.2.0-M2`, and the range still opens at `4.2.0-M1`, so the bound does not churn on every milestone and a user on an earlier Boot milestone is still offered the train. + +| Release | The train's release mapping afterwards | +|---------|----------------------------------------| +| `2026.0.0-M1` (first) | **added**, together with the `-SNAPSHOT` mapping | +| `2026.0.0-M2` | version only — `[4.2.0-M1,4.2.0-SNAPSHOT)` is unchanged | +| `2026.0.0-RC1` | the floor moves — `[4.2.0-RC1,4.2.0-SNAPSHOT)` | +| `2026.0.0-RC2` | version only | +| `2026.0.0` (GA) | widened to the next Boot minor — `[4.2.0,4.3.0-M1)` | + +The `-SNAPSHOT` mapping is written once and never rewritten, GA included. Note that after GA its range overlaps the released one for every Boot version from `4.2.0` up, since `4.2.0-SNAPSHOT` sorts below `4.2.0`; which mapping wins is then down to Initializr's resolution order. + +**Only the `-SNAPSHOT` mapping names a repository.** Spring Boot 4.0 and up publishes milestones to Maven Central — start.spring.io's own `Repositories.java` says as much — so a milestone BOM resolves without one, which is why the other pre-release entries in that file carry no `repositories` key either. Snapshots still come from `repo.spring.io`. `spring-snapshots` and `spring-milestones` are Initializr built-in repository ids; nothing has to be declared in `application.yml` to reference them. + +**Mappings for earlier trains are left alone.** They use the previous convention — a single entry per train, edited in place — and migrating them is a judgement for whoever reviews that repository. **Scoped to that bom by reading the file, not by pattern.** Half a dozen boms in that file are called `spring-cloud`-something — `spring-cloud-azure`, `spring-cloud-gcp`, `spring-cloud-services`, `solace-spring-cloud` — and each has `version:` lines of its own. The block is found by its unique `artifactId: spring-cloud-dependencies` line, then bounded by indentation: its key is the nearest line above indented less than it, and the block runs to the next line indented no further than that key. Only `version:` lines inside those bounds are considered, so a reindentation upstream cannot silently retarget this at a neighbour. -### When the line has no mapping +### When it declines to act -**No PR is opened, and the run says so.** A new mapping needs a `compatibilityRange` declaring which Spring Boot versions the train supports, and that is a judgement nothing here can make. +**No PR is opened, and the run says so**, in three cases: -This is not hypothetical: as of writing, the only mapping is `[4.0.0,4.2.0-M1) → 2025.1.2`, so a `2025.1.x` release bumps it and **a `2025.0.x` release has nothing to bump** — Boot 3.5 has aged off the site. It happens the other way round too, for the first release of a brand-new train line. +- `no-boot-version` — the properties file has no `spring-boot` entry, or one this cannot parse. Every range is derived from it. +- `unexpected-mappings` — the train has a `-SNAPSHOT` mapping but no release mapping beside it. Adding one would leave two mappings for one train and no way to tell which a later run should move. +- `bom-not-found` / `file-not-found` — the `spring-cloud` bom, or the file itself, has moved. The summary gets its own **start.spring.io PR** section spelling out the reason and listing what is currently mapped, and the Google Chat message carries a `Needs attention` bullet and a ⚠️ header. @@ -443,10 +572,14 @@ The summary gets its own **start.spring.io PR** section spelling out the reason | Status | Meaning | |--------|---------| -| `created` / `would-create` | the mapping was bumped; the diff is in the job's own summary | -| `already-current` | the mapping already names this version — a re-run, or a hand-made PR that already landed | +| `created` / `would-create` | the PR was opened; the diff is in the job's own summary | +| `added` | the train had no mappings, so both were added — its first release | +| `updated` | the train's existing mapping was rewritten | +| `already-current` | the mapping already names this version and range — a re-run, or a hand-made PR that already landed | | `branch-exists` | `-release` is already in the repo; it is left alone and the existing PR is linked | -| `no-mapping` | ⚠️ nothing to bump, see above | +| `no-boot-version` | ⚠️ no usable `spring-boot` entry to derive the range from, see above | +| `unexpected-mappings` | ⚠️ a `-SNAPSHOT` mapping with no release mapping beside it, see above | +| `error` | ❌ the edit did not produce the expected mappings, so nothing was written | | `file-not-found` / `bom-not-found` | ❌ `application.yml` moved, or the `spring-cloud` bom is no longer identifiable in it | ## The release board diff --git a/.github/workflows/create-commercial-release-branch.yml b/.github/workflows/create-commercial-release-branch.yml index a037d57..3ce007a 100644 --- a/.github/workflows/create-commercial-release-branch.yml +++ b/.github/workflows/create-commercial-release-branch.yml @@ -12,19 +12,15 @@ on: required: true type: string release-train: - description: 'Spring release train to join (e.g. 2026.09)' - required: true + description: 'Spring release train to join (e.g. 2026.09). Supplying it joins that train once the branch is prepared; leave it empty to prepare the branch without joining.' + required: false type: string + default: '' token: description: 'GitHub token with access to the project repos. Falls back to GH_ACTIONS_REPO_TOKEN.' required: false type: string default: '' - trigger-release-train-join: - description: 'Join the Spring release train after the branch is prepared. Uncheck to opt out.' - required: false - type: boolean - default: true workflow_call: inputs: @@ -37,14 +33,10 @@ on: required: true type: string release-train: - description: 'Spring release train to join (e.g. 2025.09)' - required: true - type: string - trigger-release-train-join: - description: 'Join the Spring release train after the branch is prepared. Set to false to opt out.' + description: 'Spring release train to join (e.g. 2025.09). Supplying it joins that train once the branch is prepared; leave it empty to prepare the branch without joining.' required: false - type: boolean - default: true + type: string + default: '' secrets: token: description: 'GitHub token with access to the project repos. Falls back to GH_ACTIONS_REPO_TOKEN.' @@ -209,10 +201,15 @@ jobs: commercial-branch: release/${{ steps.setup.outputs.release-version }} token: ${{ inputs.token || secrets.token || secrets.GH_ACTIONS_REPO_TOKEN }} - # Dispatch release-train-join.yml in the commercial repo and wait for it, - # unless the trigger-release-train-join input is set to false to opt out. + # Dispatch release-train-join.yml in the commercial repo and wait for it, when a + # release-train is supplied - leaving that input empty prepares the branch and stops + # short of joining. + # + # inputs['release-train'] rather than inputs.release-train: a hyphen in a dot-path is + # ambiguous to the expression parser, and getting it wrong here would silently + # evaluate falsy and skip the join rather than fail visibly. - name: Trigger release-train-join workflow and wait - if: ${{ inputs['trigger-release-train-join'] }} + if: ${{ inputs['release-train'] != '' }} env: GH_TOKEN: ${{ inputs.token || secrets.token || secrets.GH_ACTIONS_REPO_TOKEN }} run: | diff --git a/.github/workflows/create-hotfix-release-branch.yml b/.github/workflows/create-hotfix-release-branch.yml index 81133f3..b7c7c02 100644 --- a/.github/workflows/create-hotfix-release-branch.yml +++ b/.github/workflows/create-hotfix-release-branch.yml @@ -10,8 +10,9 @@ name: Create Hotfix Release Branch # .1-SNAPSHOT (e.g. 5.0.1 -> 5.0.1.1-SNAPSHOT). # Optionally, dependency versions can be updated from a release train or an # explicit project-version override can be supplied. -# Finally, release-train-join.yml is triggered in the commercial repo, unless -# the trigger_release_train_join input is set to false to opt out. +# Finally, release-train-join.yml is triggered in the commercial repo, when a +# spring_release_train is supplied — leaving that input empty prepares the branch +# and stops short of joining. on: workflow_dispatch: @@ -25,9 +26,10 @@ on: required: true type: string spring_release_train: - description: 'Spring release train this hotfix is part of (e.g. 2026.1)' - required: true + description: 'Spring release train this hotfix is part of (e.g. 2026.1). Supplying it joins that train once the branch is prepared; leave it empty to prepare the branch without joining.' + required: false type: string + default: '' project_version: description: 'Override the auto-computed hotfix project version (default: .1-SNAPSHOT)' required: false @@ -48,11 +50,6 @@ on: required: false type: string default: '' - trigger_release_train_join: - description: 'Join the Spring release train after the branch is prepared. Uncheck to opt out.' - required: false - type: boolean - default: true workflow_call: inputs: @@ -65,9 +62,10 @@ on: required: true type: string spring_release_train: - description: 'Spring release train this hotfix is part of (e.g. 2026.1)' - required: true + description: 'Spring release train this hotfix is part of (e.g. 2026.1). Supplying it joins that train once the branch is prepared; leave it empty to prepare the branch without joining.' + required: false type: string + default: '' project_version: description: 'Override the auto-computed hotfix project version (default: .1-SNAPSHOT)' required: false @@ -88,11 +86,6 @@ on: required: false type: string default: '' - trigger_release_train_join: - description: 'Dispatch release-train-join.yml in the commercial repo after the branch is prepared. Set to false to opt out.' - required: false - type: boolean - default: true secrets: token: description: 'GitHub token with contents: write on the commercial repo. Falls back to the GH_ACTIONS_REPO_TOKEN organization secret.' @@ -331,7 +324,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Trigger release-train-join workflow and wait - if: ${{ inputs.trigger_release_train_join }} + if: ${{ inputs.spring_release_train != '' }} env: GH_TOKEN: ${{ secrets.GH_ACTIONS_REPO_TOKEN }} shell: bash diff --git a/.github/workflows/create-oss-release-branch.yml b/.github/workflows/create-oss-release-branch.yml index 07f0114..b8eb1d8 100644 --- a/.github/workflows/create-oss-release-branch.yml +++ b/.github/workflows/create-oss-release-branch.yml @@ -12,14 +12,19 @@ name: Create OSS Release Branch # Create Internal Branch - 5.0.x-internal # Initialize Internal Branch - 5.0.x-internal # Create Release Branch - release/5.0.0 -run-name: "Create OSS Release Branch - ${{ inputs.oss_repo }} (${{ inputs.oss_branch }}) - ${{ inputs.spring_cloud_release_train }}" +run-name: "Create OSS Release Branch - ${{ inputs.oss_repo }} - ${{ inputs.spring_cloud_release_train }}" # Prepares a commercial release from an OSS source branch, in two stages. # # Stage 1 — the -internal branch (long-lived, one per minor line) -# The OSS branch is cloned with full history and pushed to the commercial repo as -# ..x-internal, derived from the root pom.xml version -# (e.g. pom version 5.0.0-SNAPSHOT → 5.0.x-internal). The branch is initialised with +# The OSS branch this release is cut from is derived rather than passed in: the train's +# internal snapshot properties file gives this project's version, and that version gives +# the branch — ..x when it exists, otherwise main when main is on the same +# line (the shared resolve-release-branch action). So a 2025.1.3 release of +# spring-cloud-config resolves to 5.0.x, and a 2026.0.0 one to main. +# +# That branch is cloned with full history and pushed to the commercial repo as +# ..x-internal (e.g. version 5.0.0 → 5.0.x-internal). The branch is initialised with # the commercial release CI files, registered in projects.json, and its pom versions # are rewritten from -SNAPSHOT to -INTERNAL-SNAPSHOT using the internal snapshot # properties file for the Spring Cloud release train @@ -39,11 +44,25 @@ run-name: "Create OSS Release Branch - ${{ inputs.oss_repo }} (${{ inputs.oss_br # pom versions stay at -INTERNAL-SNAPSHOT until release-train-ready stamps the real # release versions. Release branches are not registered in projects.json. # +# On a milestone or release candidate the phase is appended, so the branch is +# release/5.1.0-M1 or release/5.1.0-RC2. The phase comes from the +# spring_cloud_release_train input (2026.0.0-M1 → M1); the pom supplies the numeric +# version. Nothing in the tree can supply it: at this point every version is +# -INTERNAL-SNAPSHOT or -SNAPSHOT, so the content is identical whether this branch is +# about to become M1, RC1 or the GA. +# +# The phase is taken back out before the properties file is looked up, so every +# pre-release of a train reads one 2026_0_0-internal-snapshot.properties. The internal +# branch sits at 5.1.0-INTERNAL-SNAPSHOT from M1 through to GA and does not move +# between pre-releases, so per-phase files would be byte-identical. +# # After both branches exist: -# - A milestone is created in the OSS repo +# - A milestone is created in the OSS repo, named for the same qualified version +# (5.1.0-M1), which is the title post-release later closes and rolls forward # - release-train-join.yml and release-train-ready.yml are ensured in the release branch -# - release-train-join.yml is triggered with deployment-destination=Maven Central -# (unless the trigger_release_train_join input is set to false to opt out) +# - release-train-join.yml is triggered with deployment-destination=Maven Central, +# when a spring_release_train is supplied — leaving that input empty prepares the +# branch and stops short of joining # - The release branch's [skip actions] commits are squashed and pushed to trigger CI on: @@ -53,16 +72,13 @@ on: description: 'Open source repository name in the spring-cloud org (e.g. spring-cloud-config)' required: true type: string - oss_branch: - description: 'OSS branch to create the release branch from (e.g. main, 1.2.x)' - required: true - type: string spring_release_train: - description: 'Spring release train this release is part of (e.g. 2026.1)' - required: true + description: 'Spring release train this release is part of (e.g. 2026.1). Supplying it joins that train once the branch is prepared; leave it empty to prepare the branch without joining.' + required: false type: string + default: '' spring_cloud_release_train: - description: 'Spring Cloud release train version this release is part of (e.g. 2026.1.0). Used to look up the -INTERNAL-SNAPSHOT properties file in spring-cloud-release-commercial@jenkins-releaser-config.' + description: 'Spring Cloud release train version this release is part of (e.g. 2026.1.0, or 2026.0.0-M1 / 2026.0.0-RC2 for a pre-release). The -M/-RC names the release branch and the milestone; it is stripped before looking up the -INTERNAL-SNAPSHOT properties file in spring-cloud-release-commercial@jenkins-releaser-config, so one file serves the whole train. Pass the same value here as to release-train-ready and post-release.' required: true type: string sha: @@ -70,85 +86,193 @@ on: required: false type: string default: '' - trigger_release_train_join: - description: 'Join the Spring release train after the branch is prepared. Uncheck to opt out.' - required: false - type: boolean - default: true permissions: contents: read jobs: - # Read root pom.xml from the OSS branch to derive the release version, the internal - # branch name, and the commercial branch/repo names. + # Everything is derived from the release train version and the releaser config: the OSS + # branch this release is cut from, the release version, the internal branch name, and the + # commercial branch/repo names. Nothing is passed in but the repository and the train. derive: - name: Derive Branch Names - ${{ inputs.oss_repo }} (${{ inputs.oss_branch }}) + name: Derive Branch Names - ${{ inputs.oss_repo }} runs-on: ubuntu-latest outputs: release_version: ${{ steps.derive.outputs.release_version }} + qualified_version: ${{ steps.derive.outputs.qualified_version }} + version_qualifier: ${{ steps.derive.outputs.version_qualifier }} commercial_repo: ${{ steps.derive.outputs.commercial_repo }} commercial_project: ${{ steps.derive.outputs.commercial_project }} commercial_branch: ${{ steps.derive.outputs.commercial_branch }} internal_branch: ${{ steps.derive.outputs.internal_branch }} - internal_train_version: ${{ steps.derive.outputs.internal_train_version }} + internal_train_version: ${{ steps.train.outputs.internal_train_version }} + oss_branch: ${{ steps.derive.outputs.oss_branch }} steps: - - name: Derive release version, internal branch and commercial branch from pom.xml - id: derive + # Only so the resolve-release-branch action below can be referenced by path. The + # sha input pins it to the same commit as every other job in this workflow, so a + # run started against a particular commit of this repository uses that commit's + # actions throughout. + - name: Checkout spring-cloud-github-actions + uses: actions/checkout@v4 + with: + ref: ${{ inputs.sha || github.sha }} + + # The release train version is the only input that says which release this is. Parsed + # first because everything below depends on it: the phase names the release branch and + # the milestone, and the train with the phase taken back off names the properties file + # the project version is read from. + - name: Parse the release train version + id: train + run: | + set -euo pipefail + + # At this point in the release every version in the tree is -INTERNAL-SNAPSHOT or + # -SNAPSHOT, so nothing in the repository or in the properties file content says + # whether the branch being cut becomes M1, RC1 or the GA. The input does, and it is + # the same string passed to spring-release-train-project-ready and to post-release. + train="${{ inputs.spring_cloud_release_train }}" + + # A caller may already have appended the suffix. Compared upper-cased so the check + # is case-insensitive, then cut from the original by length so the caller's own + # casing of the part in front survives. + train_upper="$(echo "$train" | tr '[:lower:]' '[:upper:]')" + if [[ "$train_upper" == *-INTERNAL-SNAPSHOT ]]; then + stripped_upper="${train_upper%-INTERNAL-SNAPSHOT}" + train="${train:0:${#stripped_upper}}" + train_upper="$stripped_upper" + fi + + # Pull the phase off, and take it back out of the train: the internal branch sits at + # 5.1.0-INTERNAL-SNAPSHOT from M1 all the way to GA and does not move between + # pre-releases, so one 2026_0_0-internal-snapshot.properties serves the whole train. + VERSION_QUALIFIER="" + if [[ "$train_upper" =~ -(M|RC)([0-9]+)$ ]]; then + VERSION_QUALIFIER="${BASH_REMATCH[1]}${BASH_REMATCH[2]}" + stripped_upper="${train_upper%-${VERSION_QUALIFIER}}" + train="${train:0:${#stripped_upper}}" + fi + + INTERNAL_TRAIN_VERSION="${train}-INTERNAL-SNAPSHOT" + + # The same rule update-project-versions uses to resolve this file later, so the + # version read here and the versions stamped there always come from one file. + INTERNAL_PROPS_FILE=$(node "${GITHUB_WORKSPACE}/.github/scripts/releaser-config-file.js" \ + "$INTERNAL_TRAIN_VERSION") + + echo "Release train: ${{ inputs.spring_cloud_release_train }}" + echo "Phase: ${VERSION_QUALIFIER:-GA}" + echo "Internal train: ${INTERNAL_TRAIN_VERSION}" + echo "Properties file: ${INTERNAL_PROPS_FILE}" + + echo "internal_train_version=${INTERNAL_TRAIN_VERSION}" >> "$GITHUB_OUTPUT" + echo "version_qualifier=${VERSION_QUALIFIER}" >> "$GITHUB_OUTPUT" + echo "props_file=${INTERNAL_PROPS_FILE}" >> "$GITHUB_OUTPUT" + + # The project version comes from the properties file rather than from a pom, because the + # branch holding that pom is what is being derived. The file is authoritative for what + # this train is releasing, and update-project-versions stamps from the same one a few + # steps later, so the two cannot disagree. + - name: Read this project's version from the releaser config + id: version env: GH_TOKEN: ${{ secrets.GH_ACTIONS_REPO_TOKEN }} + PROPS_FILE: ${{ steps.train.outputs.props_file }} + INTERNAL_TRAIN_VERSION: ${{ steps.train.outputs.internal_train_version }} + PROJECT: ${{ inputs.oss_repo }} run: | - echo "Reading pom.xml from spring-cloud/${{ inputs.oss_repo }} at ${{ inputs.oss_branch }}..." - pom=$(gh api "repos/spring-cloud/${{ inputs.oss_repo }}/contents/pom.xml?ref=${{ inputs.oss_branch }}" \ - --jq '.content' | tr -d '\n' | base64 -d) - echo "$pom" > /tmp/pom.xml - - release_version=$(node -e " - const fs = require('fs'); - const data = fs.readFileSync('/tmp/pom.xml', 'utf8'); - const noParent = data.replace(/[\s\S]*?<\/parent>/g, ''); - const m = noParent.match(/([^<]+)<\/version>/); - if (!m) { process.stderr.write('No found in pom.xml\n'); process.exit(1); } - process.stdout.write(m[1].replace(/-SNAPSHOT\$/, '') + '\n'); - ") + set -euo pipefail + + # Where the config lives, the file name and the lookup all come from the shared + # module, so this reads the same file update-project-versions stamps from a few + # steps later. rc captured through || rather than read from $? after an `if !`, + # where $? is the result of the negation and always 0. + rc=0 + raw=$(node "${GITHUB_WORKSPACE}/.github/scripts/releaser-config.js" \ + "$INTERNAL_TRAIN_VERSION" "$PROJECT") || rc=$? + if [[ "$rc" -ne 0 ]]; then + case "$rc" in + 3) echo "::error::Could not read ${PROPS_FILE} from the releaser config." + echo "::error::Check that spring_cloud_release_train names a train that has one." ;; + 4) echo "::error::'${PROJECT}' is not in ${PROPS_FILE}, so it is not part of this" + echo "::error::release train and no release branch should be cut for it." ;; + *) echo "::error::Could not read the releaser config (exit ${rc})." ;; + esac + exit 1 + fi - # The internal branch covers the whole minor line, so the patch segment is - # dropped: 5.0.0 -> 5.0.x-internal, 4.2.5 -> 4.2.x-internal. Derived from the - # pom version rather than the OSS branch name so that main and 4.2.x both work. + # 5.1.0-INTERNAL-SNAPSHOT -> 5.1.0. The qualifier is dropped whatever it is, so a + # plain -SNAPSHOT entry works the same way. + release_version="${raw%%-*}" if [[ ! "$release_version" =~ ^[0-9]+\.[0-9]+(\..+)?$ ]]; then - echo "ERROR: pom version '${release_version}' is not .[.] —" - echo "cannot derive the internal branch name from it." + echo "::error::'${raw}' in ${PROPS_FILE} is not .[.] —" + echo "::error::cannot derive the branch names from it." exit 1 fi + + echo "Config entry: ${raw}" + echo "Release version: ${release_version}" + echo "release_version=${release_version}" >> "$GITHUB_OUTPUT" + + # Which OSS branch this release is cut from: ..x when it exists, else main + # when main is on that same line. Shared with post-release.yml and update-versions.yml. + - name: Resolve the OSS branch + id: oss_branch + uses: ./.github/actions/resolve-release-branch + with: + repo: spring-cloud/${{ inputs.oss_repo }} + version: ${{ steps.version.outputs.release_version }} + commercial: 'false' + token: ${{ secrets.GH_ACTIONS_REPO_TOKEN }} + + - name: Derive branch names + id: derive + env: + release_version: ${{ steps.version.outputs.release_version }} + VERSION_QUALIFIER: ${{ steps.train.outputs.version_qualifier }} + OSS_BRANCH: ${{ steps.oss_branch.outputs.branch }} + OSS_BRANCH_STATUS: ${{ steps.oss_branch.outputs.status }} + OSS_BRANCH_MESSAGE: ${{ steps.oss_branch.outputs.message }} + run: | + set -euo pipefail + + # The action reports rather than fails so a matrix caller can carry on; this + # workflow targets one project, so an unresolved branch is the end of the run. + if [[ "$OSS_BRANCH_STATUS" != "ok" ]]; then + echo "::error::Could not resolve the OSS branch for ${{ inputs.oss_repo }}: ${OSS_BRANCH_MESSAGE}" + exit 1 + fi + + # The internal branch covers the whole minor line, so the patch segment is + # dropped: 5.0.0 -> 5.0.x-internal, 4.2.5 -> 4.2.x-internal. INTERNAL_BRANCH="$(echo "$release_version" | cut -d. -f1-2).x-internal" - # The internal snapshot properties file is looked up by release train version: - # 2026.1.0 -> 2026.1.0-INTERNAL-SNAPSHOT -> 2026_1_0-internal-snapshot.properties - train="${{ inputs.spring_cloud_release_train }}" - train_upper="$(echo "$train" | tr '[:lower:]' '[:upper:]')" - if [[ "$train_upper" == *-INTERNAL-SNAPSHOT ]]; then - INTERNAL_TRAIN_VERSION="$train" + # Everything this release is named after carries the phase: release/5.1.0-M1 and a + # 5.1.0-M1 milestone, which are the branch post-release merges back and the + # milestone it closes before opening 5.1.0-M2. + if [[ -n "$VERSION_QUALIFIER" ]]; then + QUALIFIED_VERSION="${release_version}-${VERSION_QUALIFIER}" else - INTERNAL_TRAIN_VERSION="${train}-INTERNAL-SNAPSHOT" + QUALIFIED_VERSION="$release_version" fi COMMERCIAL_REPO="spring-cloud/${{ inputs.oss_repo }}-commercial" COMMERCIAL_PROJECT="${{ inputs.oss_repo }}-commercial" - COMMERCIAL_BRANCH="release/${release_version}" + COMMERCIAL_BRANCH="release/${QUALIFIED_VERSION}" - echo "release_version=${release_version}" >> "$GITHUB_OUTPUT" - echo "commercial_repo=${COMMERCIAL_REPO}" >> "$GITHUB_OUTPUT" - echo "commercial_project=${COMMERCIAL_PROJECT}" >> "$GITHUB_OUTPUT" - echo "commercial_branch=${COMMERCIAL_BRANCH}" >> "$GITHUB_OUTPUT" - echo "internal_branch=${INTERNAL_BRANCH}" >> "$GITHUB_OUTPUT" - echo "internal_train_version=${INTERNAL_TRAIN_VERSION}" >> "$GITHUB_OUTPUT" + echo "release_version=${release_version}" >> "$GITHUB_OUTPUT" + echo "qualified_version=${QUALIFIED_VERSION}" >> "$GITHUB_OUTPUT" + echo "oss_branch=${OSS_BRANCH}" >> "$GITHUB_OUTPUT" + echo "commercial_repo=${COMMERCIAL_REPO}" >> "$GITHUB_OUTPUT" + echo "commercial_project=${COMMERCIAL_PROJECT}" >> "$GITHUB_OUTPUT" + echo "commercial_branch=${COMMERCIAL_BRANCH}" >> "$GITHUB_OUTPUT" + echo "internal_branch=${INTERNAL_BRANCH}" >> "$GITHUB_OUTPUT" echo "OSS repo: spring-cloud/${{ inputs.oss_repo }}" - echo "OSS branch: ${{ inputs.oss_branch }}" + echo "OSS branch: ${OSS_BRANCH}" echo "Release version: ${release_version}" + echo "Qualified version: ${QUALIFIED_VERSION}" echo "Commercial repo: ${COMMERCIAL_REPO}" echo "Internal branch: ${INTERNAL_BRANCH}" - echo "Internal train: ${INTERNAL_TRAIN_VERSION}" echo "Commercial branch: ${COMMERCIAL_BRANCH}" # Full clone of the OSS branch (preserving history) and push to the commercial repo as @@ -174,8 +298,8 @@ jobs: exit 0 fi - echo "Cloning ${oss_repo}@${{ inputs.oss_branch }} (full history)..." - git clone --branch "${{ inputs.oss_branch }}" \ + echo "Cloning ${oss_repo}@${{ needs.derive.outputs.oss_branch }} (full history)..." + git clone --branch "${{ needs.derive.outputs.oss_branch }}" \ "https://x-access-token:${GH_TOKEN}@github.com/${oss_repo}.git" source-repo echo "Pushing to ${commercial_repo} as ${internal_branch}..." @@ -221,7 +345,7 @@ jobs: uses: ./.github/actions/update-projects-json with: oss-repo: spring-cloud/${{ inputs.oss_repo }} - oss-branch: ${{ inputs.oss_branch }} + oss-branch: ${{ needs.derive.outputs.oss_branch }} commercial-branch: ${{ needs.derive.outputs.internal_branch }} remove-oss-branch: 'false' token: ${{ secrets.GH_ACTIONS_REPO_TOKEN }} @@ -282,7 +406,7 @@ jobs: if gh api "repos/${commercial_repo}/git/ref/heads/${commercial_branch}" --silent 2>/dev/null; then echo "ERROR: ${commercial_branch} already exists in ${commercial_repo}." - echo "Version ${{ needs.derive.outputs.release_version }} looks like it has already been released." + echo "Version ${{ needs.derive.outputs.qualified_version }} looks like it has already been released." exit 1 fi @@ -328,7 +452,7 @@ jobs: # Create a milestone in the OSS repo (not the commercial repo) for the release version. create-milestone: - name: Create Milestone - ${{ needs.derive.outputs.release_version }} + name: Create Milestone - ${{ needs.derive.outputs.qualified_version }} needs: derive runs-on: ubuntu-latest steps: @@ -339,7 +463,10 @@ jobs: uses: ./.github/actions/create-milestone with: repo: spring-cloud/${{ inputs.oss_repo }} - version: ${{ needs.derive.outputs.release_version }} + # The qualified version, so a milestone release opens 5.1.0-M1 rather than 5.1.0. + # post-release closes this exact title and opens the next one, and dependabot-scan + # resolves PRs against it, so it has to match what the release is actually called. + version: ${{ needs.derive.outputs.qualified_version }} token: ${{ secrets.GH_ACTIONS_REPO_TOKEN }} # Check whether the OSS branch already contains the required release train workflow @@ -360,7 +487,7 @@ jobs: uses: ./.github/actions/check-release-train-workflows with: repo: spring-cloud/${{ inputs.oss_repo }} - branch: ${{ inputs.oss_branch }} + branch: ${{ needs.derive.outputs.oss_branch }} token: ${{ secrets.GH_ACTIONS_REPO_TOKEN }} - name: Determine primary JDK for the generator @@ -371,7 +498,7 @@ jobs: run: | # Look up the primary JDK for this OSS branch from projects.json. project_key="${{ inputs.oss_repo }}" - oss_branch="${{ inputs.oss_branch }}" + oss_branch="${{ needs.derive.outputs.oss_branch }}" echo "JDK lookup: oss.jdkVersions['${oss_branch}'] in project '${project_key}'" gh api repos/spring-cloud/spring-cloud-github-actions/contents/config/projects.json \ @@ -426,7 +553,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Trigger release-train-join workflow and wait - if: ${{ inputs.trigger_release_train_join }} + if: ${{ inputs.spring_release_train != '' }} env: GH_TOKEN: ${{ secrets.GH_ACTIONS_REPO_TOKEN }} run: | diff --git a/.github/workflows/post-release.yml b/.github/workflows/post-release.yml index 45bb738..ea6670f 100644 --- a/.github/workflows/post-release.yml +++ b/.github/workflows/post-release.yml @@ -61,9 +61,18 @@ on: type: boolean default: true release_version: - description: 'Spring Cloud release train version that was just released (e.g. 2025.1.2, or 2025.1.2.1 for a commercial hotfix)' + description: 'Spring Cloud release train version that was just released (e.g. 2025.1.2, 2026.0.0-M1 or 2026.0.0-RC1, or 2025.1.2.1 for a commercial hotfix)' required: true type: string + promote_to: + description: 'Where the train goes next. Leave as none to stay in the current phase (M1 -> M2, RC1 -> RC2). RC moves a milestone train to RC1; GA moves a release candidate train to its final version. Ignored for GA and hotfix releases, which always bump the last segment.' + required: false + type: choice + default: 'none' + options: + - none + - RC + - GA commercial: description: 'Was this a commercial release? Ignored when projects is supplied - the -commercial suffix on the project names decides.' required: false @@ -116,37 +125,76 @@ jobs: props-file: ${{ steps.parse.outputs.props-file }} next-version: ${{ steps.parse.outputs.next-version }} next-file: ${{ steps.parse.outputs.next-file }} + next-versions: ${{ steps.parse.outputs.next-versions }} + prerelease: ${{ steps.parse.outputs.prerelease }} entries: ${{ steps.parse.outputs.entries }} steps: + # Only for .github/scripts/prerelease-rank.js, which owns the M/RC grammar shared + # with dependabot-scan and dependabot-triage.yml. Every other job here reaches + # GitHub over the API rather than the working tree, which is why this job had no + # checkout before. + - uses: actions/checkout@v4 + - name: Parse releaser config and build matrix id: parse env: GH_TOKEN: ${{ inputs.token || secrets.GH_ACTIONS_REPO_TOKEN }} RELEASE_VERSION: ${{ inputs.release_version }} + PROMOTE_TO: ${{ inputs.promote_to }} COMMERCIAL: ${{ inputs.commercial }} PROJECTS_FILTER: ${{ inputs.projects }} run: | node - << 'JSEOF' const fs = require('fs'); + const path = require('path'); const { execFileSync } = require('child_process'); + // The M/RC grammar and the rules for advancing a train, shared with + // dependabot-scan and dependabot-triage.yml. This job checks the repository + // out, so the module sits under the workspace. + const prerelease = require(path.join(process.env.GITHUB_WORKSPACE, + '.github', 'scripts', 'prerelease-rank.js')); + const { releaserConfigFileName } = require(path.join(process.env.GITHUB_WORKSPACE, + '.github', 'scripts', 'releaser-config-file.js')); + const { RELEASER_CONFIG_REPO, RELEASER_CONFIG_BRANCH, fetchReleaserConfig } = + require(path.join(process.env.GITHUB_WORKSPACE, + '.github', 'scripts', 'releaser-config.js')); + const releaseVersion = (process.env.RELEASE_VERSION || '').trim(); + const promoteTo = (process.env.PROMOTE_TO || 'none').trim(); const projectsRaw = (process.env.PROJECTS_FILTER || '').trim(); const commercialInput = (process.env.COMMERCIAL || 'false') === 'true'; const fail = (...msg) => { for (const m of msg) console.error(m); process.exit(1); }; - // Pre-release qualifiers are rejected rather than half-handled: bumping the patch of - // something like 2025.1.0-RC1 produces a version nobody wants, and post-release chores - // are not run for milestones or release candidates anyway. - if (!/^\d+(\.\d+){2,3}$/.test(releaseVersion)) { - fail(`ERROR: release_version must be a plain numeric version with 3 or 4 segments ` + - `(e.g. 2025.1.2 or 2025.1.2.1); got '${releaseVersion}'.`); + // 3 or 4 numeric segments, optionally carrying an -M or -RC qualifier. + // split() returns null for anything else - a -SNAPSHOT, a -INTERNAL-SNAPSHOT, a + // qualifier outside the grammar - which would resolve to a properties file that + // cannot exist, so it is rejected here rather than as a confusing 404 later. + const parsed = prerelease.split(releaseVersion); + if (!parsed) { + fail(`ERROR: release_version must be 3 or 4 numeric segments with an optional ` + + `-M or -RC qualifier (e.g. 2025.1.2, 2026.0.0-M1, 2026.0.0-RC1 or ` + + `2025.1.2.1); got '${releaseVersion}'.`); } + // A milestone or release candidate. The train does not advance during a pre-release + // cycle - the maintenance branch stays on -SNAPSHOT from M1 all the way to + // GA - so there is no next snapshot train to write and no version bump to push. + // What does happen is the milestones, the releases, the merge back, the website + // entry and the board rollover, each named after the next pre-release rather than + // the next patch. + const isPrerelease = parsed.kind !== null; + // A 4-segment version is a commercial hotfix: only the tag gate and the - // milestone/release step apply to it. - const hotfix = releaseVersion.split('.').length === 4; + // milestone/release step apply to it. Counted on the base so that a qualifier + // cannot be mistaken for a fourth segment. + const hotfix = parsed.base.split('.').length === 4; + + if (isPrerelease && hotfix) { + fail(`ERROR: '${releaseVersion}' is both a 4-segment hotfix version and a ` + + 'pre-release. Commercial hotfixes do not have milestones or release candidates.'); + } // ── projects filter ───────────────────────────────────────────────────────── // Commerciality comes from the names, not from the commercial input, because the @@ -179,36 +227,23 @@ jobs: } } - // Always spring-cloud-release-commercial, for OSS trains too: that repository holds - // the releaser config for every train now, so this is deliberately NOT derived from - // `commercial`. That input still decides everything else - which project repos are - // acted on, whether release notes are sanitized, and the OSS tag fallback. - const releaseRepo = 'spring-cloud/spring-cloud-release-commercial'; - const propsFile = releaseVersion.replace(/\./g, '_') + '.properties'; + // Where the config lives, and the name of the file, both from the shared module - + // it owns that choice for every caller. `commercial` still decides everything else + // here: which project repos are acted on, whether release notes are sanitized, and + // the OSS tag fallback. + const releaseRepo = RELEASER_CONFIG_REPO; + const propsFile = releaserConfigFileName(releaseVersion); // ── read the properties file ──────────────────────────────────────────────── - console.log(`Reading ${propsFile} from ${releaseRepo}@jenkins-releaser-config...`); - let content; + console.log(`Reading ${propsFile} from ${releaseRepo}@${RELEASER_CONFIG_BRANCH}...`); + let entries; try { - const b64 = execFileSync('gh', ['api', - `repos/${releaseRepo}/contents/${propsFile}?ref=jenkins-releaser-config`, - '--jq', '.content'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); - content = Buffer.from(b64.replace(/\s/g, ''), 'base64').toString('utf8'); + entries = fetchReleaserConfig(releaseVersion).entries; } catch (err) { - fail(`ERROR: could not read ${propsFile} from ${releaseRepo}@jenkins-releaser-config.`, + fail(`ERROR: ${err.message}.`, 'Check that release_version matches a properties file on that branch, and that the', 'token has read access to the repository.'); } - - const ENTRY_RE = /^releaser\.fixed-versions\[([^\]]+)\]=(.+)$/; - const entries = []; - for (const line of content.split('\n')) { - const m = line.match(ENTRY_RE); - if (m) entries.push({ key: m[1].trim(), version: m[2].trim() }); - } - if (!entries.length) { - fail(`ERROR: ${propsFile} contains no releaser.fixed-versions[...] entries.`); - } console.log(`Found ${entries.length} version entries.`); // spring-boot lives in the properties file but is not a Spring Cloud repository, so it @@ -247,33 +282,91 @@ jobs: })) .sort((a, b) => a.project.localeCompare(b.project)); - // ── next snapshot train version ───────────────────────────────────────────── - const bump = v => { - const parts = v.split('.'); - const last = parts[parts.length - 1]; - if (!/^\d+$/.test(last)) return null; - parts[parts.length - 1] = String(Number(last) + 1); - return parts.join('.'); - }; - + // ── where the train goes next ─────────────────────────────────────────────── + // Two different things, and only the first exists on a pre-release run: + // + // nextVersion - the next release's train version. Names the new milestones and + // the new project board. Always computed (except for a hotfix). + // nextFile - the next -snapshot.properties file. GA only: a + // pre-release cycle does not move the train off its snapshot, so + // there is no new file to write and nothing to bump on the + // maintenance branch. + // + // prerelease.next() throws on a transition that cannot be meant - a milestone + // promoted straight to GA, a release candidate promoted to a release candidate - + // rather than returning a version that would go on to name milestones and boards. let nextVersion = ''; let nextFile = ''; + const nextVersions = {}; if (!hotfix) { - const unbumpable = entries.filter(e => bump(e.version) === null); - if (unbumpable.length) { - fail('ERROR: these versions do not end in a numeric segment and cannot be bumped:', - ...unbumpable.map(e => ` ${e.key}=${e.version}`)); + try { + nextVersion = prerelease.next(releaseVersion, promoteTo); + } catch (err) { + fail(`ERROR: ${err.message}`); + } + + if (isPrerelease) { + // Each project advances the same way the train does, from its own version: + // spring-cloud-config 5.1.0-M1 -> 5.1.0-M2. Derived per entry rather than read + // from a snapshot file, because on this path there is no snapshot file to + // read - next-snapshot-config does not run. + // + // Only entries that are themselves pre-releases are advanced. An entry sitting + // at a GA version during a pre-release train is either spring-boot or a module + // pinned to a released version, and in neither case does the next milestone of + // this train imply a next version of it - advancing spring-boot 4.0.0 to 4.0.1 + // would invent a Spring Boot release. Those keys are simply absent from the + // map, and the jobs that read it skip a project with no entry. + const unadvanceable = []; + const notAdvanced = []; + for (const e of entries) { + if (NON_REPO_KEYS.has(e.key) || !prerelease.isPrerelease(e.version)) { + notAdvanced.push(`${e.key}=${e.version}`); + continue; + } + try { + nextVersions[e.key] = prerelease.next(e.version, promoteTo); + } catch (err) { + unadvanceable.push(` ${e.key}=${e.version}: ${err.message}`); + } + } + if (unadvanceable.length) { + fail(`ERROR: ${propsFile} holds versions that cannot be advanced to the ` + + 'next pre-release:', ...unadvanceable); + } + if (!Object.keys(nextVersions).length) { + fail(`ERROR: no entry in ${propsFile} is a pre-release version, but ` + + `release_version '${releaseVersion}' is one. Check that release_version ` + + 'matches the properties file that was actually released.'); + } + if (notAdvanced.length) { + console.log(`Not advanced (not a pre-release): ${notAdvanced.join(', ')}`); + } + } else { + // GA: next-snapshot-config writes the new file and publishes the per-project + // versions, so nothing is derived here. Named through the shared rule with the + // qualifier spelled out rather than by pasting '-snapshot.properties' on the + // end, so the snapshot file and the release file are built the same way. + nextFile = releaserConfigFileName(`${nextVersion}-SNAPSHOT`); } - nextVersion = bump(releaseVersion); - nextFile = nextVersion.replace(/\./g, '_') + '-snapshot.properties'; } console.log(''); console.log(`commercial: ${commercial}`); console.log(`hotfix: ${hotfix}`); + console.log(`pre-release: ${isPrerelease}${isPrerelease ? ` (${parsed.kind}${parsed.num})` : ''}`); + console.log(`promote_to: ${promoteTo}`); console.log(`release repo: ${releaseRepo}`); console.log(`properties file: ${propsFile}`); - if (!hotfix) console.log(`next snapshot: ${nextVersion} (${nextFile})`); + if (!hotfix) console.log(`next release: ${nextVersion}`); + if (!hotfix && !isPrerelease) console.log(`next snapshot: ${nextFile}`); + if (isPrerelease) { + console.log(''); + console.log('This is a pre-release, so the train stays on its current snapshot:'); + console.log(' - no -snapshot.properties is written'); + console.log(' - the release branch is merged back, but no version bump is pushed'); + console.log(' - milestones and the board roll over to ' + nextVersion); + } console.log(''); console.log(`Projects to process: ${matrix.length}`); for (const e of matrix) console.log(` ${e.repo} @ ${e.tag}`); @@ -287,6 +380,8 @@ jobs: fs.appendFileSync(out, `props-file=${propsFile}\n`); fs.appendFileSync(out, `next-version=${nextVersion}\n`); fs.appendFileSync(out, `next-file=${nextFile}\n`); + fs.appendFileSync(out, `next-versions=${JSON.stringify(nextVersions)}\n`); + fs.appendFileSync(out, `prerelease=${isPrerelease}\n`); fs.appendFileSync(out, `entries=${JSON.stringify(entries)}\n`); JSEOF @@ -394,13 +489,19 @@ jobs: next-snapshot-config: name: Next Snapshot Config needs: [setup, verify-tags] - if: needs.setup.outputs.hotfix != 'true' + # A pre-release does not advance the train: 5.1.x stays on 5.1.0-SNAPSHOT from M1 all + # the way to GA, so there is no next snapshot file to write. Every job that consumes + # this one's versions reads setup.outputs.next-versions instead on that path. + if: needs.setup.outputs.hotfix != 'true' && needs.setup.outputs.prerelease != 'true' runs-on: ubuntu-latest outputs: status: ${{ steps.write.outputs.status }} available: ${{ steps.write.outputs.available }} versions: ${{ steps.write.outputs.versions }} steps: + # Only so the script below can require .github/scripts/. + - uses: actions/checkout@v4 + - name: Write the next snapshot properties file id: write env: @@ -413,6 +514,9 @@ jobs: run: | node - << 'JSEOF' const fs = require('fs'); + const path = require('path'); + const { parseReleaserConfig, ENTRY_RE } = require(path.join( + process.env.GITHUB_WORKSPACE, '.github', 'scripts', 'releaser-config.js')); const { execFileSync } = require('child_process'); const releaseRepo = process.env.RELEASE_REPO; @@ -437,13 +541,10 @@ jobs: } catch (err) { return null; } }; - const ENTRY_RE = /^releaser\.fixed-versions\[([^\]]+)\]=(.+)$/; + // The shared parse, folded into the {key: version} map this job works in. const parse = text => { const map = {}; - for (const line of text.split('\n')) { - const m = line.match(ENTRY_RE); - if (m) map[m[1].trim()] = m[2].trim(); - } + for (const e of parseReleaserConfig(text)) map[e.key] = e.version; return map; }; @@ -574,6 +675,13 @@ jobs: new-milestones: name: "New Milestone — ${{ matrix.project }}" needs: [setup, verify-tags, next-snapshot-config] + # next-snapshot-config is skipped on a pre-release run, which would otherwise skip + # this job with it. verify-tags is still a hard gate either way. + if: | + !cancelled() && + needs.verify-tags.result == 'success' && + (needs.next-snapshot-config.result == 'success' || + needs.setup.outputs.prerelease == 'true') runs-on: ubuntu-latest strategy: fail-fast: false @@ -592,13 +700,22 @@ jobs: GH_TOKEN: ${{ inputs.token || secrets.GH_ACTIONS_REPO_TOKEN }} PROJECT: ${{ matrix.project }} REPO: ${{ matrix.repo }} - VERSIONS: ${{ needs.next-snapshot-config.outputs.versions }} + VERSIONS: ${{ needs.setup.outputs.prerelease == 'true' + && needs.setup.outputs.next-versions + || needs.next-snapshot-config.outputs.versions }} run: | set -euo pipefail + # The map holds the next snapshot version on a GA run (5.1.1-SNAPSHOT) and the + # next pre-release on a milestone or RC run (5.1.0-M2), so the -SNAPSHOT strip is + # a no-op on the latter. Either way the result is the milestone title. + # + # A project missing from the map gets no milestone. On a pre-release run that is + # how a module pinned to a GA version is skipped: there is no next pre-release of + # it to open a milestone for. TITLE=$(echo "$VERSIONS" | jq -r --arg k "$PROJECT" '.[$k] // ""' | sed 's/-SNAPSHOT$//') if [[ -z "$TITLE" ]]; then - echo "No snapshot version for ${PROJECT} - nothing to create." + echo "No next version for ${PROJECT} - nothing to create." echo "title=" >> "$GITHUB_OUTPUT" echo "exists=unknown" >> "$GITHUB_OUTPUT" exit 0 @@ -681,6 +798,13 @@ jobs: merge-back-and-update: name: "Merge Back + Bump — ${{ matrix.project }}" needs: [setup, verify-tags, next-snapshot-config] + # next-snapshot-config is skipped on a pre-release run, which would otherwise skip + # this job with it. verify-tags is still a hard gate either way. + if: | + !cancelled() && + needs.verify-tags.result == 'success' && + (needs.next-snapshot-config.result == 'success' || + needs.setup.outputs.prerelease == 'true') runs-on: ubuntu-latest strategy: fail-fast: false @@ -695,102 +819,55 @@ jobs: persist-credentials: false # ── 5a. resolve the branch to work on ───────────────────────────────────────────── - - name: Resolve target branch - id: branch + # Two steps and a normalizer rather than one script: the .x-then-main resolution is + # shared with update-versions.yml and create-oss-release-branch.yml and lives in the + # resolve-release-branch action. What stays here is only what is specific to this + # workflow - reading this project's version out of the versions map, and reporting a + # project that is not in the map at all as no-version rather than as a bad version. + - name: Read this project's next version + id: version env: - GH_TOKEN: ${{ inputs.token || secrets.GH_ACTIONS_REPO_TOKEN }} PROJECT: ${{ matrix.project }} - REPO: ${{ matrix.repo }} - COMMERCIAL: ${{ needs.setup.outputs.commercial }} - VERSIONS: ${{ needs.next-snapshot-config.outputs.versions }} + VERSIONS: ${{ needs.setup.outputs.prerelease == 'true' + && needs.setup.outputs.next-versions + || needs.next-snapshot-config.outputs.versions }} run: | - node - << 'JSEOF' - const fs = require('fs'); - const { execFileSync } = require('child_process'); - - const project = process.env.PROJECT; - const repo = process.env.REPO; - const commercial = process.env.COMMERCIAL === 'true'; - const versions = JSON.parse(process.env.VERSIONS); - - const out = process.env.GITHUB_OUTPUT; - const emit = (k, v) => fs.appendFileSync(out, `${k}=${v}\n`); - const stop = (status, message) => { - console.log(message); - emit('status', status); - emit('branch', ''); - process.exit(0); - }; - - const snapshot = versions[project]; - if (!snapshot) stop('no-version', `No snapshot version for ${project}.`); - - // Drop the last segment and append .x. Works for both lines: OSS 5.0.3 -> 5.0.x, and - // 3-part commercial 4.2.8 -> 4.2.x. - const plain = snapshot.replace(/-SNAPSHOT$/, ''); - const parts = plain.split('.'); - const target = parts.slice(0, -1).join('.') + '.x'; - - const branchExists = branch => { - try { - execFileSync('gh', ['api', `repos/${repo}/branches/${branch}`, '--jq', '.name'], - { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); - return true; - } catch (err) { return false; } - }; - - if (branchExists(target)) { - console.log(`Target branch: ${target}`); - emit('status', 'ok'); - emit('branch', target); - process.exit(0); - } - - // Commercial repos have no main branch at all - spring-cloud-config-commercial's - // default is 4.3.x - so there is no sane fallback to make. - if (commercial) { - stop('branch-not-found', - `ERROR: ${repo} has no ${target} branch, and commercial repos have no main to ` + - 'fall back to. Skipping this project.'); - } - - if (!branchExists('main')) { - stop('branch-not-found', `ERROR: ${repo} has neither ${target} nor main.`); - } - - // Falling back to main is only safe if main really is the line this version belongs - // to - otherwise we would bump an unrelated major.minor to these versions. - const expected = parts.slice(0, 2).join('.'); - let pom; - try { - const b64 = execFileSync('gh', ['api', - `repos/${repo}/contents/pom.xml?ref=main`, '--jq', '.content'], - { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); - pom = Buffer.from(b64.replace(/\s/g, ''), 'base64').toString('utf8'); - } catch (err) { - stop('branch-not-found', `ERROR: no ${target} branch and could not read pom.xml on main.`); - } - - // The root is the project's own; fall back to when the - // root pom inherits it. - const withoutParent = pom.replace(/[\s\S]*?<\/parent>/, ''); - let m = withoutParent.match(/([^<]+)<\/version>/); - if (!m) { - const parent = pom.match(/[\s\S]*?<\/parent>/); - if (parent) m = parent[0].match(/([^<]+)<\/version>/); - } - const pomVersion = m ? m[1].trim() : ''; + set -euo pipefail + VERSION=$(echo "$VERSIONS" | jq -r --arg k "$PROJECT" '.[$k] // ""') + if [[ -z "$VERSION" ]]; then + echo "No version for ${PROJECT}." + else + echo "Version for ${PROJECT}: ${VERSION}" + fi + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - if (!pomVersion.startsWith(`${expected}.`)) { - stop('version-mismatch', - `ERROR: ${repo} has no ${target} branch, and main is at '${pomVersion}', which is ` + - `not on the ${expected} line. Refusing to bump main to ${snapshot}.`); - } + - name: Resolve target branch + id: resolve + if: steps.version.outputs.version != '' + uses: ./.github/actions/resolve-release-branch + with: + repo: ${{ matrix.repo }} + version: ${{ steps.version.outputs.version }} + commercial: ${{ needs.setup.outputs.commercial }} + token: ${{ inputs.token || secrets.GH_ACTIONS_REPO_TOKEN }} - console.log(`No ${target} branch; main is at ${pomVersion} - using main.`); - emit('status', 'ok'); - emit('branch', 'main'); - JSEOF + # Keeps the id every later step already reads, so the resolution moving into an action + # is invisible to the rest of the job. + - name: Branch resolution result + id: branch + env: + VERSION: ${{ steps.version.outputs.version }} + RESOLVED: ${{ steps.resolve.outputs.branch }} + STATUS: ${{ steps.resolve.outputs.status }} + run: | + set -euo pipefail + if [[ -z "$VERSION" ]]; then + echo "status=no-version" >> "$GITHUB_OUTPUT" + echo "branch=" >> "$GITHUB_OUTPUT" + else + echo "status=${STATUS}" >> "$GITHUB_OUTPUT" + echo "branch=${RESOLVED}" >> "$GITHUB_OUTPUT" + fi # ── 5b. merge release/ back into the .x branch ────────────────────────── - name: Merge release branch back @@ -997,7 +1074,14 @@ jobs: id: bump # The available check keeps a dry run honest: nothing was committed, so there is no # snapshot file to resolve versions from and this is reported as skipped, not failed. + # + # Skipped outright for a pre-release. The train does not advance between M1 and GA, + # so the maintenance branch is already on the versions it should be on - bumping it + # would move 5.1.x off 5.1.0-SNAPSHOT and start building a train that does not + # exist. The merge above still runs: the release branch's changes have to rejoin + # the line whatever kind of release it was. if: | + needs.setup.outputs.prerelease != 'true' && needs.next-snapshot-config.outputs.available == 'true' && (steps.merge.outputs.status == 'merged' || steps.merge.outputs.status == 'already-merged' || @@ -1016,13 +1100,23 @@ jobs: directory: project # ── 5d. one push carrying the merge commit and the version bump ────────────────── + # On a pre-release run there is no bump, so this pushes the merge commit alone. The + # gate names both cases explicitly rather than relying on a skipped step's outcome: + # a skipped step reports 'skipped', which the old `outcome == 'success'` test would + # have read as "the bump failed, push nothing" and silently dropped the merge. - name: Commit and push id: push - if: steps.bump.outcome == 'success' + if: | + steps.bump.outcome == 'success' || + (needs.setup.outputs.prerelease == 'true' && + (steps.merge.outputs.status == 'merged' || + steps.merge.outputs.status == 'already-merged' || + steps.merge.outputs.status == 'no-release-branch')) env: REPO: ${{ matrix.repo }} BRANCH: ${{ steps.branch.outputs.branch }} NEXT_VERSION: ${{ needs.setup.outputs.next-version }} + PRERELEASE: ${{ needs.setup.outputs.prerelease }} MERGE_STATUS: ${{ steps.merge.outputs.status }} DRY_RUN: ${{ inputs.dry_run }} run: | @@ -1032,6 +1126,12 @@ jobs: git add -A if git diff --cached --quiet; then echo "No version changes to commit." + elif [[ "$PRERELEASE" == "true" ]]; then + # Nothing should have changed the versions on this path, so anything staged here + # is unexpected. Commit it with the merge rather than dropping it on the floor, + # but say plainly that it is not a version bump. + git commit --quiet -m "Merging ${MERGE_STATUS} changes into the release line" + echo "::warning::Unexpected working tree changes on a pre-release run - committed alongside the merge." else # Deliberately no [skip actions] here, unlike most workflows in this repo: the # whole point of pushing the new snapshot versions is to start CI on them. @@ -1218,6 +1318,7 @@ jobs: TAG: ${{ matrix.tag }} RESOLVED_IN: ${{ matrix.resolvedIn }} COMMERCIAL: ${{ needs.setup.outputs.commercial }} + PRERELEASE: ${{ needs.setup.outputs.prerelease }} ENTRIES: ${{ needs.setup.outputs.entries }} SKIP_CLOSE_MILESTONES: ${{ inputs.skip_close_milestones }} DRY_RUN: ${{ inputs.dry_run }} @@ -1232,6 +1333,7 @@ jobs: const tag = process.env.TAG; const resolvedIn = process.env.RESOLVED_IN; const commercial = process.env.COMMERCIAL === 'true'; + const isPrerelease = process.env.PRERELEASE === 'true'; const skipCloseMilestones = process.env.SKIP_CLOSE_MILESTONES === 'true'; const dryRun = process.env.DRY_RUN === 'true'; const entries = JSON.parse(process.env.ENTRIES); @@ -1446,8 +1548,22 @@ jobs: // and every commercial repo use a v prefix. const name = (commercial || project === 'spring-cloud-release') ? tag : version; + // A milestone or release candidate is flagged as a pre-release, and explicitly + // kept from becoming the repository's "Latest" release. Without make_latest + // GitHub promotes the newest release by date, so publishing 5.1.0-M1 would put + // it above the current 5.0.x GA on every project page and in the API that + // downstream tooling reads to find the current version. + const payload = { tag_name: tag, name, body }; + if (isPrerelease) { + payload.prerelease = true; + // A string, not a boolean: the REST API defines make_latest as an enum of + // "true" | "false" | "legacy", and a JSON boolean is rejected. + payload.make_latest = 'false'; + } + if (dryRun) { - console.log(`[dry run] would create release '${name}' for ${tag} in ${repo}:`); + console.log(`[dry run] would create release '${name}' for ${tag} in ${repo}` + + `${isPrerelease ? ' (pre-release, not marked latest)' : ''}:`); console.log('---'); console.log(body); console.log('---'); @@ -1456,7 +1572,7 @@ jobs: // Via --input rather than repeated --field, so a body containing newlines, // backticks or quotes cannot be mangled on the way through. - fs.writeFileSync('payload.json', JSON.stringify({ tag_name: tag, name, body })); + fs.writeFileSync('payload.json', JSON.stringify(payload)); const created = JSON.parse(gh(['api', `repos/${repo}/releases`, '--method', 'POST', '--input', 'payload.json'])); console.log(`Created release ${created.html_url}`); @@ -1525,9 +1641,13 @@ jobs: inputs.skip_website_pr != true && inputs.projects == '' && (needs.setup.outputs.commercial == 'true' || + needs.setup.outputs.prerelease == 'true' || needs.next-snapshot-config.result == 'success') runs-on: ubuntu-latest steps: + # Only so the script below can require .github/scripts/. + - uses: actions/checkout@v4 + - name: Build the website changes and open the PR id: run env: @@ -1537,12 +1657,17 @@ jobs: COMMERCIAL: ${{ needs.setup.outputs.commercial }} ENTRIES: ${{ needs.setup.outputs.entries }} RESOLVED: ${{ needs.verify-tags.outputs.matrix }} - NEXT_VERSIONS: ${{ needs.next-snapshot-config.outputs.versions }} + NEXT_VERSIONS: ${{ needs.setup.outputs.prerelease == 'true' + && needs.setup.outputs.next-versions + || needs.next-snapshot-config.outputs.versions }} + PRERELEASE: ${{ needs.setup.outputs.prerelease }} DRY_RUN: ${{ inputs.dry_run }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | node - << 'JSEOF' const fs = require('fs'); + const { parseReleaserConfig, ENTRY_RE } = require(path.join( + process.env.GITHUB_WORKSPACE, '.github', 'scripts', 'releaser-config.js')); const path = require('path'); const { execFileSync } = require('child_process'); @@ -1555,6 +1680,7 @@ jobs: const entries = JSON.parse(process.env.ENTRIES); const resolved = JSON.parse(process.env.RESOLVED).include; const nextVersions = JSON.parse(process.env.NEXT_VERSIONS || '{}'); + const isPrereleaseRun = process.env.PRERELEASE === 'true'; const WEBSITE = commercial ? 'spring-io/spring-website-commercial-content' @@ -1598,7 +1724,6 @@ jobs: return 0; }; - const ENTRY_RE = /^releaser\.fixed-versions\[([^\]]+)\]=(.+)$/; const [major, minor] = releaseVersion.split('.'); const trainLine = `${major}.${minor}`; @@ -1647,10 +1772,7 @@ jobs: '--jq', '.content']); const text = Buffer.from(b64.replace(/\s/g, ''), 'base64').toString('utf8'); previousVersions = {}; - for (const line of text.split('\n')) { - const m = line.match(ENTRY_RE); - if (m) previousVersions[m[1].trim()] = m[2].trim(); - } + for (const e of parseReleaserConfig(text)) previousVersions[e.key] = e.version; console.log(`Comparing against ${result.previousFile} to find what was released.`); } } catch (err) { @@ -1707,6 +1829,33 @@ jobs: `https://x-access-token:${token}@github.com/${WEBSITE}.git`, 'website']); const boot = (entries.find(e => e.key === 'spring-boot') || {}).version || ''; + + // How this release describes itself. A milestone and a release candidate are + // announced like a GA release but published somewhere else - the Spring milestone + // repository rather than Maven Central - so the wording and the link both change, + // and the dependency snippets below need a block a GA post does not. + const phase = (() => { + const m = releaseVersion.match(/-(M|RC)(\d+)$/); + if (!m) { + return { + prerelease: false, + blurb: 'the General Availability (RELEASE) of', + repoName: 'Maven Central', + repoUrl: 'https://repo1.maven.org/maven2/org/springframework/cloud/' + + `spring-cloud-dependencies/${releaseVersion}/`, + }; + } + const label = m[1] === 'M' ? `Milestone ${m[2]} (M${m[2]})` + : `Release Candidate ${m[2]} (RC${m[2]})`; + return { + prerelease: true, + blurb: `the ${label} of`, + repoName: 'the Spring milestone repository', + repoUrl: 'https://repo.spring.io/milestone/org/springframework/cloud/' + + `spring-cloud-dependencies/${releaseVersion}/`, + milestoneRepoUrl: 'https://repo.spring.io/milestone', + }; + })(); const today = new Date().toISOString().slice(0, 10); const dashed = releaseVersion.replace(/\./g, '-'); @@ -1755,11 +1904,10 @@ jobs: B.push(`author: ${AUTHOR}`); B.push('---'); B.push(''); - B.push('On behalf of the community, I am pleased to announce that the General ' + - `Availability (RELEASE) of the [Spring Cloud ${releaseVersion}]` + + B.push('On behalf of the community, I am pleased to announce that ' + + `${phase.blurb} the [Spring Cloud ${releaseVersion}]` + '(https://cloud.spring.io) Release Train is available today. The release can be ' + - 'found in [Maven Central](https://repo1.maven.org/maven2/org/springframework/' + - `cloud/spring-cloud-dependencies/${releaseVersion}/). You can check out the ` + + `found in [${phase.repoName}](${phase.repoUrl}). You can check out the ` + `${releaseVersion} [release notes for more information](https://github.com/` + `spring-cloud/spring-cloud-release/wiki/Spring-Cloud-${trainLine}-Release-Notes).`); B.push(''); @@ -1820,6 +1968,21 @@ jobs: B.push(' '); B.push(' ...'); B.push(''); + if (phase.prerelease) { + // Without this the snippet does not build: a milestone BOM is not on Central, + // and the reader's first move after copying it is an unresolvable dependency. + B.push(''); + B.push(''); + B.push(' '); + B.push(' spring-milestones'); + B.push(' Spring Milestones'); + B.push(` ${phase.milestoneRepoUrl}`); + B.push(' '); + B.push(' false'); + B.push(' '); + B.push(' '); + B.push(''); + } B.push('```'); B.push(''); B.push('or with Gradle:'); @@ -1846,6 +2009,13 @@ jobs: B.push("compile 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'"); B.push('...'); B.push('}'); + if (phase.prerelease) { + B.push(''); + B.push('repositories {'); + B.push('mavenCentral()'); + B.push(`maven { url '${phase.milestoneRepoUrl}' }`); + B.push('}'); + } B.push('```'); fs.mkdirSync(path.dirname(`website/${blogFile}`), { recursive: true }); @@ -2005,7 +2175,10 @@ jobs: // template's version to begin with. The entry is still added, since the version // and tag are right and a missing entry is worse, but it is reported so the PR // says which field to look at. - const strays = [...new Set((block.join('\n').match(/\d+\.\d+\.\d+(?:\.\d+)?/g) || []) + // The qualifier is part of the version: without it, cloning for 5.1.0-M1 would + // match the bare 5.1.0 inside it and report the entry's own version as a stray. + const strays = [...new Set( + (block.join('\n').match(/\d+\.\d+\.\d+(?:\.\d+)?(?:-(?:M|RC)\d+)?/g) || []) .filter(v => v !== e.version))]; if (strays.length) { rec.strays = strays; @@ -2064,11 +2237,23 @@ jobs: // whose version is unchanged simply produces no diff, and its snapshot entry still // has to follow the new snapshot train. Each file holds one GENERAL_AVAILABILITY // and one SNAPSHOT entry per major.minor line, so the released version's line - // picks out exactly the two entries to update. + // picks out exactly the entries to update. + // + // A train that ships milestones adds a third status to that line, and the site + // carries exactly one of them at a time - the entry is promoted rather than + // accumulated: + // + // 5.1.0-M1 no entry on the line yet, so one is created PRERELEASE 5.1.0-M1 + // 5.1.0-M2 the same entry, rewritten PRERELEASE 5.1.0-M2 + // 5.1.0-RC1 the same entry, rewritten PRERELEASE 5.1.0-RC1 + // 5.1.0 the same entry, rewritten and promoted GA 5.1.0 + // + // So a reader never sees a milestone listed next to the GA that supersedes it, and + // the line only ever grows by one entry however many pre-releases it takes. for (const e of (commercial ? [] : projects)) { const rel = `project/${docFor(e.key)}/documentation.json`; const file = `website/${rel}`; - const rec = { project: e.key, file: rel, ga: '', snapshot: '', status: 'unchanged' }; + const rec = { project: e.key, file: rel, ga: '', snapshot: '', prerelease: '', status: 'unchanged' }; result.docs.push(rec); // spring-cloud-build has no page on the website, so it has no file here. @@ -2102,28 +2287,239 @@ jobs: return true; }; + // Rewrites the status of the entry that currently carries `version`. Same + // one-match-or-nothing rule as replaceVersion, and matched on the pair so that + // two entries sharing a status cannot be confused for each other. + const replaceStatus = (version, newStatus) => { + const quoted = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + // Anchored on the version so the status of a neighbouring entry cannot be + // rewritten instead. The version is re-emitted rather than left to a capture + // group spanning it, because dropping it here would blank the field. + const re = new RegExp( + `("version"\\s*:\\s*")${quoted}("[\\s\\S]{0,400}?"status"\\s*:\\s*")[A-Z_]+(")`); + const before = text; + text = text.replace(re, `$1${version}$2${newStatus}$3`); + return text !== before; + }; + + // No githubTag handling here, unlike the commercial pass above: entries on the + // OSS site do not carry that field at all. An entry is identified by its version + // and its ref, and both are handled explicitly. + const target = lineOf(e.version); - const ga = list.find(x => - x.status === 'GENERAL_AVAILABILITY' && lineOf(x.version) === target); - const snap = list.find(x => - x.status === 'SNAPSHOT' && lineOf(x.version) === target); + const onLine = x => x.version && lineOf(x.version) === target; + const ga = list.find(x => x.status === 'GENERAL_AVAILABILITY' && onLine(x)); + const snap = list.find(x => x.status === 'SNAPSHOT' && onLine(x)); + const pre = list.find(x => x.status === 'PRERELEASE' && onLine(x)); const nextSnap = nextVersions[e.key] || ''; - if (!ga && !snap) { - rec.status = 'no-entry'; - console.log(`${rel}: no ${target} entry - skipped.`); - continue; - } - if (ga && ga.version !== e.version) { - if (replaceVersion(ga.version, e.version)) rec.ga = `${ga.version} → ${e.version}`; - else rec.status = 'ambiguous'; + if (isPrereleaseRun) { + // The snapshot entry is deliberately untouched: main has not moved off its + // snapshot, so there is nothing to follow. nextVersions is empty on this path + // anyway, but the intent is worth stating. + if (pre) { + if (pre.version !== e.version) { + if (replaceVersion(pre.version, e.version)) { + rec.prerelease = `${pre.version} → ${e.version}`; + } else { + rec.status = 'ambiguous'; + } + } + } else { + // First pre-release of this line, so there is no entry to rewrite. Creating + // one is handled below, after the in-place cases, because it splices lines + // rather than editing them. + rec.status = 'needs-entry'; + } + } else { + if (!ga && !snap && !pre) { + rec.status = 'no-entry'; + console.log(`${rel}: no ${target} entry - skipped.`); + continue; + } + if (ga && ga.version !== e.version) { + if (replaceVersion(ga.version, e.version)) rec.ga = `${ga.version} → ${e.version}`; + else rec.status = 'ambiguous'; + } + // The GA that concludes a pre-release cycle. With no GA entry on the line the + // pre-release entry becomes it, which is the whole point of promoting in place; + // with one already there the pre-release entry is stale and its version is + // folded into the GA entry above, so it is dropped rather than left behind + // advertising an RC that GA has superseded. + if (pre) { + if (!ga) { + const versionOk = pre.version === e.version || + replaceVersion(pre.version, e.version); + if (versionOk && replaceStatus(e.version, 'GENERAL_AVAILABILITY')) { + rec.ga = `${pre.version} → ${e.version} (promoted from PRERELEASE)`; + // `current` marks the one release the site points people at. The entry + // being promoted has carried current: false since it was a milestone, so + // without this the GA of a whole new line would ship marked not-current + // while the previous line kept the flag. An ordinary GA release never hits + // this: it edits an entry that already holds the flag. + // + // Done over the entry blocks rather than by regex around the version, + // because field order is not consistent across these files - most write + // version first, spring-cli writes it last - so "the current after this + // version" is not a safe thing to match on. + const curLines = text.split('\n'); + const curBlocks = entryBlocks(curLines); + if (curBlocks.length === list.length) { + const promoted = list.indexOf(pre); + let flagged = false; + for (let bi = 0; bi < list.length; bi++) { + const want = bi === promoted; + for (let ln = curBlocks[bi].start; ln <= curBlocks[bi].end; ln++) { + const next = curLines[ln].replace( + /^(\s*"current"\s*:\s*)(?:true|false)/, `$1${want}`); + if (next !== curLines[ln]) { curLines[ln] = next; flagged = true; } + } + } + // A file with no current field anywhere is left exactly as it was. + if (flagged) text = curLines.join('\n'); + } else { + problem(`${rel}: could not line up entries to move the 'current' flag ` + + `onto ${e.version} - set it by hand.`); + } + } else { + rec.status = 'ambiguous'; + } + } else { + rec.prerelease = `${pre.version} superseded by ${e.version}`; + problem(`${rel}: the ${target} line has both a GENERAL_AVAILABILITY entry ` + + `and a PRERELEASE entry for ${pre.version}. The GA entry was updated; ` + + 'remove the stale pre-release entry by hand.'); + } + } + if (snap && nextSnap && snap.version !== nextSnap) { + if (replaceVersion(snap.version, nextSnap)) { + rec.snapshot = `${snap.version} → ${nextSnap}`; + } else { + rec.status = 'ambiguous'; + } + } } - if (snap && nextSnap && snap.version !== nextSnap) { - if (replaceVersion(snap.version, nextSnap)) { - rec.snapshot = `${snap.version} → ${nextSnap}`; + + if (rec.status === 'needs-entry') { + // ── first pre-release of a new line: clone an entry for it ────────────── + // Same shape as the commercial path above and for the same reason: these + // files have no single entry shape to assemble from fields, so the newest + // entry that already looks right is copied as text. + const lines = text.split('\n'); + const blocks = entryBlocks(lines); + if (blocks.length !== list.length) { + rec.status = 'unrecognised-format'; + console.log(`${rel}: not one block per entry - left alone.`); + continue; + } + + // Which entry to copy, and it matters which. On this site a pre-release entry's + // ref is keyed to the *line*, not the version - spring-data-jpa carries + // + // PRERELEASE 4.2.0-M1 ref .../reference/4.2/ + // SNAPSHOT 4.2.0-SNAPSHOT ref .../reference/4.2-SNAPSHOT/ + // + // so the same line's SNAPSHOT entry is the right template: it is the only entry + // already pointing at this line, and turning it into the pre-release entry is + // one substitution, `-SNAPSHOT` -> ``. Cloning a previous line's GA + // entry instead would leave the ref on that line - substituting 5.0.5 -> 5.1.0-M1 + // never touches a ref that reads .../reference/5.0/ - and quietly point the new + // milestone at the old line's documentation. + const snapAnchor = list.findIndex(x => x.status === 'SNAPSHOT' && onLine(x)); + + // Fallback for a line with no snapshot entry yet. The line token is substituted + // as well as the version, so the ref still lands on the right line. + const versionInRef = x => { + const ref = x.ref || ''; + return ref.includes('{version}') || (!!x.version && ref.includes(x.version)); + }; + const gaCandidates = list + .map((x, i) => i) + .filter(i => list[i].status === 'GENERAL_AVAILABILITY' && + NUMERIC.test(list[i].version || '') && !versionInRef(list[i])); + const gaAnchor = gaCandidates.reduce( + (best, i) => (best < 0 || cmp(list[i].version, list[best].version) > 0 ? i : best), + -1); + + const anchor = snapAnchor >= 0 ? snapAnchor : gaAnchor; + if (anchor < 0) { + rec.status = 'no-template'; + console.log(`${rel}: no ${target} snapshot entry and no released Antora entry ` + + 'to clone one from - skipped.'); + continue; + } + + const template = list[anchor]; + const fromLine = lineOf(template.version); + let block = lines.slice(blocks[anchor].start, blocks[anchor].end + 1); + if (snapAnchor >= 0) { + // 5.1-SNAPSHOT -> 5.1 in ref and api. Bounded on the left so 15.1 cannot match. + const snapToken = new RegExp( + `(? l.replace(snapToken, fromLine)); } else { - rec.status = 'ambiguous'; + const lineToken = new RegExp( + `(? l.replace(bounded, e.version).replace(lineToken, target)); + } + + // version, status and current are set rather than substituted: the version field + // must end up exactly this release whatever the template held, the template's + // status is SNAPSHOT or GENERAL_AVAILABILITY and either would misdescribe this + // entry, and `current` marks the one release a project points people at - which + // a milestone never is. Every PRERELEASE entry on the site reads current: false. + block = block.map(l => l + .replace(/^(\s*"version"\s*:\s*")[^"]*(")/, `$1${e.version}$2`) + .replace(/^(\s*"status"\s*:\s*")[A-Z_]+(")/, '$1PRERELEASE$2') + .replace(/^(\s*"current"\s*:\s*)(?:true|false)/, '$1false')); + + const rendered = block.join('\n'); + if (!/"status"\s*:\s*"PRERELEASE"/.test(rendered) || + !new RegExp(`"version"\\s*:\\s*"${e.version.replace(/\./g, '\\.')}"`).test(rendered)) { + rec.status = 'no-template'; + console.log(`${rel}: the ${template.version} entry has no version or status ` + + 'field to set - left alone.'); + continue; } + + // Newest first or oldest first differs by file, so the new entry goes on + // whichever end already holds the newest version. Anything else reorders the + // file around it. + const newestFirst = list.length > 1 && + cmp(list[0].version || '0', list[list.length - 1].version || '0') > 0; + if (newestFirst) { + block[block.length - 1] = ' },'; + lines.splice(blocks[0].start, 0, ...block); + } else if (lines[blocks[list.length - 1].end] === ' }') { + lines[blocks[list.length - 1].end] = ' },'; + block[block.length - 1] = ' }'; + lines.splice(blocks[list.length - 1].end + 1, 0, ...block); + } else { + block[block.length - 1] = ' },'; + lines.splice(blocks[list.length - 1].end + 1, 0, ...block); + } + + // The splice is a text edit, so prove the result is still the same JSON plus + // one entry before writing it. + const updated = lines.join('\n'); + let check = null; + try { check = JSON.parse(updated); } catch (err) { /* check stays null */ } + if (!check || check.length !== list.length + 1 || + !check.some(x => x.version === e.version && x.status === 'PRERELEASE')) { + rec.status = 'error'; + console.log(`${rel}: the edit did not produce the expected JSON - left alone.`); + continue; + } + + fs.writeFileSync(file, updated); + rec.status = 'added'; + rec.prerelease = `added ${e.version}`; + rec.from = template.version; + console.log(`${rel}: added ${e.version} as PRERELEASE, cloned from ` + + `${template.version} - first ${target} entry`); + continue; } if (text !== original) { @@ -2131,7 +2527,9 @@ jobs: if (rec.status === 'unchanged') rec.status = 'updated'; } console.log(`${rel}: ${rec.status}` + - `${rec.ga ? ` · GA ${rec.ga}` : ''}${rec.snapshot ? ` · ${rec.snapshot}` : ''}`); + `${rec.ga ? ` · GA ${rec.ga}` : ''}` + + `${rec.prerelease ? ` · pre-release ${rec.prerelease}` : ''}` + + `${rec.snapshot ? ` · ${rec.snapshot}` : ''}`); } // ── e. commit, push, open the PR ──────────────────────────────────────────── @@ -2376,23 +2774,45 @@ jobs: inputs.projects == '' runs-on: ubuntu-latest steps: + # Only so the script below can require .github/scripts/. + - uses: actions/checkout@v4 + - name: Bump the Spring Cloud version and open the PR id: run env: GH_TOKEN: ${{ inputs.token || secrets.GH_ACTIONS_REPO_TOKEN }} RELEASE_VERSION: ${{ inputs.release_version }} + PRERELEASE: ${{ needs.setup.outputs.prerelease }} + ENTRIES: ${{ needs.setup.outputs.entries }} DRY_RUN: ${{ inputs.dry_run }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | node - << 'JSEOF' const fs = require('fs'); + const path = require('path'); const { execFileSync } = require('child_process'); + const prerelease = require(path.join(process.env.GITHUB_WORKSPACE, + '.github', 'scripts', 'prerelease-rank.js')); + const { rangeFor, snapshotRangeFor } = require(path.join( + process.env.GITHUB_WORKSPACE, '.github', 'scripts', + 'boot-compatibility-range.js')); + const token = process.env.GH_TOKEN; const releaseVersion = process.env.RELEASE_VERSION; + const isPrerelease = process.env.PRERELEASE === 'true'; const dryRun = process.env.DRY_RUN === 'true'; const runUrl = process.env.RUN_URL || ''; + // The compatibility range is anchored on the Spring Boot version this train is + // built against, which is in the same properties file as everything else. + const entries = JSON.parse(process.env.ENTRIES || '[]'); + const bootVersion = (entries.find(e => e.key === 'spring-boot') || {}).version || ''; + + // Filled in by whichever branch of the edit runs, so the summary and the PR body + // do not have to re-derive what changed. + let changeDetail = ''; + const REPO = 'spring-io/start.spring.io'; const BASE = 'main'; const FILE = 'start-site/src/main/resources/application.yml'; @@ -2420,9 +2840,6 @@ jobs: process.exit(0); }; - const lineOf = v => v.split('.').slice(0, 2).join('.'); - const target = lineOf(releaseVersion); - // ── a. is there already a branch for this release? ────────────────────────── let branchExists = false; try { @@ -2445,12 +2862,12 @@ jobs: git(['clone', '--quiet', '--depth', '1', '--branch', BASE, `https://x-access-token:${token}@github.com/${REPO}.git`, 'start']); - const path = `start/${FILE}`; - if (!fs.existsSync(path)) { + const filePath = `start/${FILE}`; + if (!fs.existsSync(filePath)) { finish('file-not-found', `${FILE} is not in ${REPO} - it has moved.`); } - const original = fs.readFileSync(path, 'utf8'); + const original = fs.readFileSync(filePath, 'utf8'); const lines = original.split('\n'); const indentOf = l => l.match(/^(\s*)/)[1].length; @@ -2481,35 +2898,154 @@ jobs: } console.log(`spring-cloud bom: ${FILE}:${keyLine + 1}-${blockEnd}`); - const mappings = []; + // Each mapping is an item starting at a `- compatibilityRange:` line and running + // to the next one, or to the end of the bom block. Handled as lines rather than + // parsed YAML so this stays a splice and the other 700 lines of that file keep + // their formatting exactly. + const ITEM = /^(\s*)-\s+compatibilityRange:\s*(.*?)\s*$/; + + const items = []; for (let i = keyLine + 1; i < blockEnd; i++) { - const m = lines[i].match(VERSION_RE); - if (m) mappings.push({ index: i, version: m[3], prefix: m[1], quote: m[2], trail: m[4] }); + const m = lines[i].match(ITEM); + if (m) items.push({ start: i, indent: m[1], range: m[2], end: blockEnd }); } - result.mappings = mappings.map(m => m.version); + for (let k = 0; k < items.length - 1; k++) items[k].end = items[k + 1].start; + if (!items.length) { + finish('bom-not-found', + `The spring-cloud bom in ${FILE} has no compatibilityRange mappings.`); + } + for (const it of items) { + for (let i = it.start; i < it.end; i++) { + const m = lines[i].match(VERSION_RE); + if (m) { it.versionIndex = i; it.version = m[3]; it.vQuote = m[2]; break; } + } + } + result.mappings = items.map(it => it.version).filter(Boolean); console.log(`Versions currently mapped: ${result.mappings.join(', ') || 'none'}`); - const hit = mappings.find(m => lineOf(m.version) === target); - if (!hit) { - // Deliberately not a PR. A new mapping needs a compatibilityRange saying which - // Spring Boot versions this train supports, and that is not derivable from - // anything this workflow reads. - finish('no-mapping', - `No ${target}.x mapping in ${FILE} - start.spring.io does not currently offer ` + - `this train line. Add a mapping with the right compatibilityRange by hand; ` + - 'nothing was changed.'); + if (!bootVersion) { + finish('no-boot-version', + `The properties file for ${releaseVersion} has no spring-boot entry, and the ` + + 'compatibility range is derived from it. Nothing was changed.'); } - result.from = hit.version; - if (hit.version === releaseVersion) { - finish('already-current', - `${FILE} already maps ${target}.x to ${releaseVersion}.`); + // This train's own entries, found by version rather than by Boot range: a train + // that has never shipped has no range to match on, and the old lineOf() test - the + // first two segments - cannot pick 2026.0.0 out of 2024.0.2 and 2025.1.0-M3 either. + const trainBase = prerelease.split(releaseVersion).base; + const belongsToTrain = v => !!v && (v === trainBase || v.startsWith(`${trainBase}-`)); + const isSnapshotVersion = v => /-SNAPSHOT$/i.test(v || ''); + const ours = items.filter(it => belongsToTrain(it.version)); + const snapshotEntry = ours.find(it => isSnapshotVersion(it.version)); + const releaseEntry = ours.find(it => !isSnapshotVersion(it.version)); + + let wantRange, wantSnapshotRange; + try { + wantRange = rangeFor(bootVersion, releaseVersion); + wantSnapshotRange = snapshotRangeFor(bootVersion); + } catch (err) { + finish('no-boot-version', `Cannot derive a compatibility range: ${err.message}`); } + const snapshotVersion = `${trainBase}-SNAPSHOT`; + console.log(`Spring Boot ${bootVersion} -> ${wantRange} for ${releaseVersion}`); + + if (releaseEntry) { + // ── c. this train already has an entry: rewrite it in place ─────────────── + // Both fields, because the range moves as the train changes phase: the floor + // goes -M1 -> -RC1, and at GA the whole range widens to the next Boot minor. + const fromVersion = releaseEntry.version; + const fromRange = releaseEntry.range; + result.from = fromVersion; + + if (fromVersion === releaseVersion && + fromRange.replace(/^["']|["']$/g, '') === wantRange) { + finish('already-current', + `${FILE} already maps ${wantRange} to ${releaseVersion}.`); + } + if (releaseEntry.versionIndex === undefined) { + finish('unexpected-mappings', + `The ${fromRange} mapping in ${FILE} has no version line to rewrite.`); + } - // ── c. the one-line change ───────────────────────────────────────────────── - lines[hit.index] = `${hit.prefix}${hit.quote}${releaseVersion}${hit.quote}${hit.trail}`; - fs.writeFileSync(path, lines.join('\n')); - console.log(`${FILE}:${hit.index + 1} ${hit.version} -> ${releaseVersion}`); + lines[releaseEntry.start] = + `${releaseEntry.indent}- compatibilityRange: "${wantRange}"`; + const vq = releaseEntry.vQuote; + lines[releaseEntry.versionIndex] = lines[releaseEntry.versionIndex] + .replace(VERSION_RE, `$1${vq}${releaseVersion}${vq}$4`); + + result.action = 'updated'; + changeDetail = `\`${fromRange}\` → \`"${wantRange}"\` and ` + + `\`${fromVersion}\` → \`${releaseVersion}\``; + console.log(`${FILE}:${releaseEntry.start + 1} ${fromRange} -> "${wantRange}"`); + console.log(`${FILE}:${releaseEntry.versionIndex + 1} ${fromVersion} -> ${releaseVersion}`); + } else if (!snapshotEntry) { + // ── c. first release of this train: add both entries ────────────────────── + // The release itself, and the train's snapshot line. Only the snapshot entry + // names a repository: Spring Boot 4.0 and up publishes milestones to Maven + // Central, so a milestone bom resolves without one - which is why the other + // pre-release entries in that file carry no repositories key - while snapshots + // still come from repo.spring.io. + const last = items[items.length - 1]; + const ind = last.indent; + const block = [ + `${ind}- compatibilityRange: "${wantRange}"`, + `${ind} version: ${releaseVersion}`, + `${ind}- compatibilityRange: "${wantSnapshotRange}"`, + `${ind} version: ${snapshotVersion}`, + `${ind} repositories:`, + `${ind} - spring-snapshots`, + ]; + + // After the last mapping's last non-blank line, so a blank line separating this + // bom from the next one stays where it is. + let at = last.end - 1; + while (at > last.start && !lines[at].trim()) at--; + lines.splice(at + 1, 0, ...block); + + result.action = 'added'; + result.added = [releaseVersion, snapshotVersion]; + changeDetail = `added \`${wantRange}\` → \`${releaseVersion}\` and ` + + `\`${wantSnapshotRange}\` → \`${snapshotVersion}\``; + console.log(`Added two mappings after ${FILE}:${at + 1}:`); + for (const l of block) console.log(l); + } else { + // A snapshot entry but nothing to release into. Adding a second entry here would + // leave two mappings for one train and no way to tell which a later run should + // move, so this is reported rather than guessed at. + finish('unexpected-mappings', + `${FILE} has a ${snapshotEntry.version} mapping for this train but no release ` + + 'mapping beside it. Sort that out by hand; nothing was changed.'); + } + + // The splice is a text edit, so prove the result still has the shape intended + // before writing it. The bom block is re-scanned rather than the YAML parsed - the + // runner has no YAML library, and what matters is that the mappings list grew by + // what was expected and carries this release exactly once. + const after = lines.slice(); + const aAnchor = after.findIndex(l => ANCHOR.test(l)); + let aKey = -1; + for (let k = aAnchor - 1; k >= 0; k--) { + if (!after[k].trim()) continue; + if (indentOf(after[k]) < indentOf(after[aAnchor])) { aKey = k; break; } + } + let aEnd = after.length; + for (let k = aAnchor + 1; k < after.length; k++) { + if (!after[k].trim()) continue; + if (indentOf(after[k]) <= indentOf(after[aKey])) { aEnd = k; break; } + } + const aItems = after.slice(aKey, aEnd).filter(l => ITEM.test(l)).length; + const aCarries = after.slice(aKey, aEnd) + .filter(l => { const m = l.match(VERSION_RE); return m && m[3] === releaseVersion; }) + .length; + const expectedItems = items.length + (result.action === 'added' ? 2 : 0); + if (aItems !== expectedItems || aCarries !== 1) { + finish('error', + `The edit did not produce the expected mappings in ${FILE}: ${aItems} ` + + `mapping(s) where ${expectedItems} were expected, ${aCarries} carrying ` + + `${releaseVersion}. Left alone.`); + } + + fs.writeFileSync(filePath, lines.join('\n')); git(['config', 'user.name', 'Spring Builds'], 'start'); git(['config', 'user.email', 'svc.spring-builds@broadcom.com'], 'start'); @@ -2525,8 +3061,7 @@ jobs: fs.appendFileSync(summaryFile, [ `## start.spring.io${dryRun ? ' — dry run, nothing was pushed' : ''}`, '', - `\`${REPO}\` · \`${branch}\` · \`${FILE}\` line ${hit.index + 1}: ` + - `\`${hit.version}\` → \`${releaseVersion}\``, + `\`${REPO}\` · \`${branch}\` · \`${FILE}\`: ${changeDetail}`, '', '```diff', diff.replace(/\n+$/, ''), @@ -2540,8 +3075,21 @@ jobs: `Generated by the [post release workflow](${runUrl}).`, '', `Spring Cloud ${releaseVersion} has been released, so the \`spring-cloud\` bom ` + - `mapping for the ${target} line moves from \`${hit.version}\` to ` + - `\`${releaseVersion}\`. The \`compatibilityRange\` is unchanged.`, + `${changeDetail}.`, + '', + result.action === 'added' + ? 'Two mappings, because this train is offered on two Spring Boot lines: the ' + + 'released version for Boot ' + bootVersion.replace(/-.*$/, '') + ', and the ' + + 'train snapshot for that line\'s snapshots. Only the snapshot mapping names a ' + + 'repository - Spring Boot 4.0 and up publishes milestones to Maven Central, so ' + + 'the released mapping resolves without one, matching the other pre-release ' + + 'entries in this file.' + : 'The `compatibilityRange` moves with the phase of the train: the floor goes ' + + '`-M1` to `-RC1` when release candidates start, and widens to the next Spring ' + + 'Boot minor at GA.', + '', + 'Mappings for earlier trains are deliberately untouched; they use the previous ' + + 'single-entry convention.', ].join('\n'); if (dryRun) { @@ -2592,7 +3140,8 @@ jobs: needs: [setup, verify-tags, next-snapshot-config, new-milestones] if: | !cancelled() && - needs.next-snapshot-config.result == 'success' && + (needs.next-snapshot-config.result == 'success' || + needs.setup.outputs.prerelease == 'true') && needs.setup.outputs.commercial != 'true' && needs.setup.outputs.hotfix != 'true' && inputs.skip_release_board != true && @@ -2606,7 +3155,9 @@ jobs: RELEASE_VERSION: ${{ inputs.release_version }} NEXT_VERSION: ${{ needs.setup.outputs.next-version }} ENTRIES: ${{ needs.setup.outputs.entries }} - NEXT_VERSIONS: ${{ needs.next-snapshot-config.outputs.versions }} + NEXT_VERSIONS: ${{ needs.setup.outputs.prerelease == 'true' + && needs.setup.outputs.next-versions + || needs.next-snapshot-config.outputs.versions }} # The columns whose contents follow the train. Anything else - Done, and whatever # else a board has grown - stays on the board being closed. CARRY_OVER_COLUMNS: 'Todo,In Progress' @@ -3034,6 +3585,7 @@ jobs: RELEASE_VERSION: ${{ inputs.release_version }} COMMERCIAL: ${{ needs.setup.outputs.commercial }} HOTFIX: ${{ needs.setup.outputs.hotfix }} + PRERELEASE: ${{ needs.setup.outputs.prerelease }} NEXT_VERSION: ${{ needs.setup.outputs.next-version }} PROJECTS_FILTER: ${{ inputs.projects }} SKIP_CLOSE_MILESTONES: ${{ inputs.skip_close_milestones }} @@ -3045,6 +3597,7 @@ jobs: const dryRun = (process.env.DRY_RUN || 'false') === 'true'; const skipCloseMilestones = (process.env.SKIP_CLOSE_MILESTONES || 'false') === 'true'; const hotfix = (process.env.HOTFIX || 'false') === 'true'; + const isPrerelease = (process.env.PRERELEASE || 'false') === 'true'; const commercial = (process.env.COMMERCIAL || 'false') === 'true'; const releaseVersion = process.env.RELEASE_VERSION || ''; const nextVersion = process.env.NEXT_VERSION || ''; @@ -3076,6 +3629,11 @@ jobs: L.push(''); L.push(`Release **${releaseVersion}** · ${commercial ? 'commercial' : 'OSS'}` + (hotfix ? ' · **hotfix** (no snapshot train, new milestone or merge back)' : '') + + // Said plainly, because the absence of a snapshot file and of a version bump is + // the single most surprising thing about one of these runs to anyone reading the + // summary who has only ever seen a GA release. + (isPrerelease ? ' · **pre-release** (the train stays on its current snapshot: ' + + 'no snapshot properties file, no version bump - the merge back still happens)' : '') + (filter ? ` · filtered to \`${filter}\`` : '') + (skipCloseMilestones ? ' · **milestones left open** (`skip_close_milestones`)' : '')); L.push(''); @@ -3092,9 +3650,10 @@ jobs: 'already-closed': '➖', 'already-exists': '➖', 'already-merged': '➖', 'nothing-to-push': '➖', 'no-release-branch': '➖', // website-pr - 'branch-exists': '➖', 'no-changes': '➖', + 'branch-exists': '➖', 'no-changes': '➖', added: '✅', 'needs-entry': '⚠️', // start-site-pr 'already-current': '➖', 'no-mapping': '⚠️', + 'unexpected-mappings': '⚠️', 'no-boot-version': '⚠️', 'file-not-found': '❌', 'bom-not-found': '❌', // release-board done: '✅', 'would-run': '🔎', partial: '⚠️', diff --git a/.github/workflows/release-train-ready.yml b/.github/workflows/release-train-ready.yml index 741eb31..712ebbf 100644 --- a/.github/workflows/release-train-ready.yml +++ b/.github/workflows/release-train-ready.yml @@ -7,10 +7,6 @@ on: description: 'Spring Cloud GitHub project name (e.g. spring-cloud-config)' required: true type: string - project-version: - description: 'Project version (e.g. 4.2.0)' - required: true - type: string spring-cloud-release-train-version: description: 'Spring Cloud release version matching the jenkins-releaser-config properties file (e.g. 2025.0.0)' required: true @@ -31,10 +27,6 @@ on: description: 'Spring Cloud GitHub project name (e.g. spring-cloud-config)' required: true type: string - project-version: - description: 'Project version (e.g. 4.2.0)' - required: true - type: string spring-cloud-release-train-version: description: 'Spring Cloud release train version matching the jenkins-releaser-config properties file (e.g. 2025.0.0)' required: true @@ -53,7 +45,7 @@ permissions: jobs: release-train-ready: - name: Release Train Ready - ${{ inputs.project }} ${{ inputs.project-version }} + name: Release Train Ready - ${{ inputs.project }} ${{ inputs.spring-cloud-release-train-version }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -62,7 +54,6 @@ jobs: uses: ./.github/actions/spring-release-train-project-ready with: project: ${{ inputs.project }} - project-version: ${{ inputs.project-version }} spring-cloud-release-train-version: ${{ inputs.spring-cloud-release-train-version }} spring-release-train-version: ${{ inputs.spring-release-train-version }} token: ${{ inputs.token || secrets.token || secrets.GH_ACTIONS_REPO_TOKEN }} diff --git a/.github/workflows/setup-next-release-train.yml b/.github/workflows/setup-next-release-train.yml index 4aa3f10..d538475 100644 --- a/.github/workflows/setup-next-release-train.yml +++ b/.github/workflows/setup-next-release-train.yml @@ -77,6 +77,9 @@ jobs: release-repo: ${{ steps.parse.outputs.release-repo }} props-file: ${{ steps.parse.outputs.props-file }} steps: + # Only so the inline scripts below can require .github/scripts/. + - uses: actions/checkout@v4 + - name: Parse releaser config and build matrix id: parse env: @@ -86,7 +89,18 @@ jobs: run: | node - << 'JSEOF' const fs = require('fs'); + const path = require('path'); const { execFileSync } = require('child_process'); + // The one implementation of this rule, shared with update-project-versions - the + // action that reads the very file this names - and with the composite actions that + // need it from bash. This job checks the repository out, so the module is under the + // workspace. + const { releaserConfigFileName } = require(path.join(process.env.GITHUB_WORKSPACE, + '.github', 'scripts', 'releaser-config-file.js')); + const { RELEASER_CONFIG_REPO, RELEASER_CONFIG_BRANCH, fetchReleaserConfig } = + require(path.join(process.env.GITHUB_WORKSPACE, + '.github', 'scripts', 'releaser-config.js')); + const trainVersion = (process.env.TRAIN_VERSION || '').trim(); const projectsRaw = (process.env.PROJECTS_FILTER || '').trim(); @@ -98,12 +112,9 @@ jobs: `qualifier (e.g. 2026.1.0-SNAPSHOT); got '${trainVersion}'.`); } - // Deliberately identical to releaseTrainVersionToFileName in - // update-project-versions/src/index.js: this job must read exactly the file the action - // will read, or it would validate one file and apply another. - const propsFile = trainVersion - .replace(/-([a-zA-Z].*)$/, (_, q) => '-' + q.toLowerCase()) - .replace(/\./g, '_') + '.properties'; + // This job must read exactly the file update-project-versions will read, or it + // would validate one file and apply another. + const propsFile = releaserConfigFileName(trainVersion); const names = projectsRaw ? projectsRaw.split(',').map(s => s.trim()).filter(Boolean) @@ -119,34 +130,23 @@ jobs: 'Use create-oss-release-branch.yml for the commercial side of a release.'); } - // Always spring-cloud-release-commercial, for OSS trains too: that repository holds the - // releaser config for every train now. - const releaseRepo = 'spring-cloud/spring-cloud-release-commercial'; - const BRANCH = 'jenkins-releaser-config'; + // Where the config lives, from the shared module - it owns that choice for every + // caller. `commercial` decides only which project repositories are updated. + const releaseRepo = RELEASER_CONFIG_REPO; + const BRANCH = RELEASER_CONFIG_BRANCH; console.log(`Reading ${propsFile} from ${releaseRepo}@${BRANCH}...`); - let content; + // content as well as entries: the CDN wait below compares what raw.githubusercontent + // serves against the bytes the API returned, so it needs the file itself. + let content, entries; try { - const b64 = execFileSync('gh', ['api', - `repos/${releaseRepo}/contents/${propsFile}?ref=${BRANCH}`, - '--jq', '.content'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); - content = Buffer.from(b64.replace(/\s/g, ''), 'base64').toString('utf8'); + ({ content, entries } = fetchReleaserConfig(trainVersion)); } catch (err) { - fail(`ERROR: could not read ${propsFile} from ${releaseRepo}@${BRANCH}.`, + fail(`ERROR: ${err.message}.`, 'This workflow applies an existing properties file, it does not create one.', 'Check that release_train_version matches a file on that branch, and that the', 'token has read access to the repository.'); } - - const ENTRY_RE = /^releaser\.fixed-versions\[([^\]]+)\]=(.+)$/; - const entries = []; - for (const line of content.split('\n')) { - const m = line.match(ENTRY_RE); - if (m) entries.push({ key: m[1].trim(), version: m[2].trim() }); - } - if (!entries.length) { - fail(`ERROR: ${propsFile} contains no releaser.fixed-versions[...] entries.`); - } console.log(`Found ${entries.length} version entries.`); // spring-boot is in the properties file so that every project picks up its version, but diff --git a/.github/workflows/test-spring-release-train-project-ready.yml b/.github/workflows/test-spring-release-train-project-ready.yml index 8fdaaf2..0d920fa 100644 --- a/.github/workflows/test-spring-release-train-project-ready.yml +++ b/.github/workflows/test-spring-release-train-project-ready.yml @@ -28,7 +28,6 @@ jobs: const requiredInputs = [ 'project', - 'project-version', 'spring-cloud-release-train-version', 'spring-release-train-version', 'token' @@ -50,6 +49,7 @@ jobs: stepNames.forEach(n => console.log(` - ${n}`)); const expectedSteps = [ + 'Resolve the project version and confirm it is unreleased', 'Checkout project release branch', 'Update project versions', 'Verify no snapshot versions', diff --git a/.github/workflows/test-verify-no-snapshot-versions.yml b/.github/workflows/test-verify-no-snapshot-versions.yml index 709e2f3..3234c7a 100644 --- a/.github/workflows/test-verify-no-snapshot-versions.yml +++ b/.github/workflows/test-verify-no-snapshot-versions.yml @@ -204,6 +204,109 @@ jobs: echo "✅ Nested plugin dependency SNAPSHOT detected; the annotated project version and the snapshot repo URL were correctly ignored" + # ── Integration test (pre-release project with allow-prerelease) ────────── + # A milestone or release candidate stamps -M/-RC on purpose and pulls in a + # mixture of milestone, release-candidate and GA dependency versions. With + # allow-prerelease set, none of that is a violation. + integration-test-prerelease-allowed: + name: Integration Test (pre-release project with allow-prerelease — should pass) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Create a milestone project mixing M, RC and GA versions + run: | + mkdir -p /tmp/prerelease-project + + cat > /tmp/prerelease-project/pom.xml << 'EOF' + + + 4.0.0 + org.springframework.cloud + spring-cloud-config + 5.1.0-M1 + pom + + 5.1.0-M1 + 5.1.0-RC1 + 4.0.0 + + + EOF + + cat > /tmp/prerelease-project/gradle.properties << 'EOF' + version=5.1.0-M1 + springBootVersion=4.0.0 + springCloudCommonsVersion=5.1.0-RC1 + EOF + + - name: Run verify-no-snapshot-versions (should pass) + uses: ./.github/actions/verify-no-snapshot-versions + with: + directory: '/tmp/prerelease-project' + allow-prerelease: 'true' + + # ── Integration test (SNAPSHOT still fails with allow-prerelease) ────────── + # allow-prerelease relaxes the milestone half of the check and nothing else. A + # -SNAPSHOT must never ship, whatever kind of release is being cut. + integration-test-prerelease-snapshot: + name: Integration Test (SNAPSHOT with allow-prerelease — expected to fail) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Create a milestone project with one SNAPSHOT left in it + run: | + mkdir -p /tmp/prerelease-snapshot-project + + cat > /tmp/prerelease-snapshot-project/pom.xml << 'EOF' + + + 4.0.0 + org.springframework.cloud + spring-cloud-config + 5.1.0-M1 + pom + + 5.1.0-RC1 + 4.0.0-SNAPSHOT + + + EOF + + - name: Run verify-no-snapshot-versions (expected to fail) + id: verify + uses: ./.github/actions/verify-no-snapshot-versions + with: + directory: '/tmp/prerelease-snapshot-project' + allow-prerelease: 'true' + continue-on-error: true + + - name: Confirm only the SNAPSHOT was reported + env: + VIOLATIONS: ${{ steps.verify.outputs.violations }} + run: | + if [[ "${{ steps.verify.outcome }}" != "failure" ]]; then + echo "❌ Expected the action to fail for the SNAPSHOT, but it did not" + exit 1 + fi + + echo "$VIOLATIONS" | jq . + + count=$(echo "$VIOLATIONS" | jq 'length') + if [[ "$count" != "1" ]]; then + echo "❌ Expected exactly 1 violation (the SNAPSHOT), got ${count}" + exit 1 + fi + + version=$(echo "$VIOLATIONS" | jq -r '.[0].version') + if [[ "$version" != "4.0.0-SNAPSHOT" ]]; then + echo "❌ Expected the violation to be 4.0.0-SNAPSHOT, got ${version}" + exit 1 + fi + + echo "✅ SNAPSHOT still rejected with allow-prerelease; the -M1 and -RC1 versions were correctly permitted" + # ── Dist is up to date ────────────────────────────────────────────────────── dist-up-to-date: name: Verify dist is up to date diff --git a/.github/workflows/update-versions.yml b/.github/workflows/update-versions.yml index dae0505..d876a52 100644 --- a/.github/workflows/update-versions.yml +++ b/.github/workflows/update-versions.yml @@ -70,6 +70,9 @@ jobs: release-repo: ${{ steps.parse.outputs.release-repo }} props-file: ${{ steps.parse.outputs.props-file }} steps: + # Only so the inline scripts below can require .github/scripts/. + - uses: actions/checkout@v4 + - name: Parse releaser config and build matrix id: parse env: @@ -80,7 +83,18 @@ jobs: run: | node - << 'JSEOF' const fs = require('fs'); + const path = require('path'); const { execFileSync } = require('child_process'); + // The one implementation of this rule, shared with update-project-versions - the + // action that reads the very file this names - and with the composite actions that + // need it from bash. This job checks the repository out, so the module is under the + // workspace. + const { releaserConfigFileName } = require(path.join(process.env.GITHUB_WORKSPACE, + '.github', 'scripts', 'releaser-config-file.js')); + const { RELEASER_CONFIG_REPO, RELEASER_CONFIG_BRANCH, fetchReleaserConfig } = + require(path.join(process.env.GITHUB_WORKSPACE, + '.github', 'scripts', 'releaser-config.js')); + const trainVersion = (process.env.TRAIN_VERSION || '').trim(); const projectsRaw = (process.env.PROJECTS_FILTER || '').trim(); @@ -96,12 +110,9 @@ jobs: `qualifier (e.g. 2025.1.3-SNAPSHOT or 2025.1.2); got '${trainVersion}'.`); } - // Deliberately identical to releaseTrainVersionToFileName in - // update-project-versions/src/index.js: this job must read exactly the file the action - // will read, or it would validate one file and apply another. - const propsFile = trainVersion - .replace(/-([a-zA-Z].*)$/, (_, q) => '-' + q.toLowerCase()) - .replace(/\./g, '_') + '.properties'; + // This job must read exactly the file update-project-versions will read, or it + // would validate one file and apply another. + const propsFile = releaserConfigFileName(trainVersion); // ── projects filter ───────────────────────────────────────────────────────── // Commerciality comes from the names when they are given, because the names are what @@ -129,37 +140,25 @@ jobs: } } - // Always spring-cloud-release-commercial, for OSS trains too: that repository holds the - // releaser config for every train now, so this is deliberately NOT derived from - // `commercial`. That input decides only which project repositories are updated. - const releaseRepo = 'spring-cloud/spring-cloud-release-commercial'; - const BRANCH = 'jenkins-releaser-config'; + // Where the config lives, from the shared module - it owns that choice for every + // caller. `commercial` decides only which project repositories are updated. + const releaseRepo = RELEASER_CONFIG_REPO; + const BRANCH = RELEASER_CONFIG_BRANCH; // ── read the properties file ──────────────────────────────────────────────── console.log(`Reading ${propsFile} from ${releaseRepo}@${BRANCH}...`); - let content; + // content as well as entries: the CDN wait below compares what raw.githubusercontent + // serves against the bytes the API returned, so it needs the file itself. + let content, entries; try { - const b64 = execFileSync('gh', ['api', - `repos/${releaseRepo}/contents/${propsFile}?ref=${BRANCH}`, - '--jq', '.content'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); - content = Buffer.from(b64.replace(/\s/g, ''), 'base64').toString('utf8'); + ({ content, entries } = fetchReleaserConfig(trainVersion)); } catch (err) { - fail(`ERROR: could not read ${propsFile} from ${releaseRepo}@${BRANCH}.`, + fail(`ERROR: ${err.message}.`, 'This workflow applies an existing properties file, it does not create one.', 'Check that release_train_version matches a file on that branch (post-release', 'writes the -snapshot file; it can also be committed by hand), and that the token', 'has read access to the repository.'); } - - const ENTRY_RE = /^releaser\.fixed-versions\[([^\]]+)\]=(.+)$/; - const entries = []; - for (const line of content.split('\n')) { - const m = line.match(ENTRY_RE); - if (m) entries.push({ key: m[1].trim(), version: m[2].trim() }); - } - if (!entries.length) { - fail(`ERROR: ${propsFile} contains no releaser.fixed-versions[...] entries.`); - } console.log(`Found ${entries.length} version entries.`); // spring-boot is in the properties file so that every project picks up its version, but @@ -264,103 +263,19 @@ jobs: persist-credentials: false # ── resolve the branch to work on ──────────────────────────────────────────────── + # The branch is always derived from the version in the properties file - there is no + # input to override it. Between the .x derivation and the main fallback the action + # makes, every branch this workflow targets is covered: the only names in projects.json + # that are not ..x are main, and the -internal branches, which are rebased + # from the OSS branches this updates and so are never targeted here. - name: Resolve target branch id: branch - env: - GH_TOKEN: ${{ inputs.token || secrets.GH_ACTIONS_REPO_TOKEN }} - REPO: ${{ matrix.repo }} - VERSION: ${{ matrix.version }} - COMMERCIAL: ${{ needs.setup.outputs.commercial }} - run: | - node - << 'JSEOF' - const fs = require('fs'); - const { execFileSync } = require('child_process'); - - const repo = process.env.REPO; - const version = process.env.VERSION; - const commercial = process.env.COMMERCIAL === 'true'; - - const out = process.env.GITHUB_OUTPUT; - const emit = (k, v) => fs.appendFileSync(out, `${k}=${v}\n`); - const stop = (status, message) => { - console.log(message); - emit('status', status); - emit('branch', ''); - process.exit(0); - }; - - const branchExists = branch => { - try { - execFileSync('gh', ['api', `repos/${repo}/branches/${branch}`, '--jq', '.name'], - { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); - return true; - } catch (err) { return false; } - }; - - // The branch is always derived from the version in the properties file - there is no - // input to override it. Between the .x derivation and the main fallback below, every - // branch this workflow targets is covered: the only names in projects.json that are - // not ..x are main, and the -internal branches, which are rebased from - // the OSS branches this updates and so are never targeted here. - // - // Drop the last segment and append .x. Works for both lines: OSS 5.0.4-SNAPSHOT -> - // 5.0.x, and 3-part commercial 4.2.9-SNAPSHOT -> 4.2.x. - const plain = version.replace(/-SNAPSHOT$/, ''); - const parts = plain.split('.'); - const target = parts.slice(0, -1).join('.') + '.x'; - - if (branchExists(target)) { - console.log(`Target branch: ${target}`); - emit('status', 'ok'); - emit('branch', target); - process.exit(0); - } - - // Commercial repos have no main branch at all - spring-cloud-config-commercial's - // default is 4.3.x - so there is no sane fallback to make. - if (commercial) { - stop('branch-not-found', - `ERROR: ${repo} has no ${target} branch, and commercial repos have no main to ` + - 'fall back to. Skipping this project.'); - } - - if (!branchExists('main')) { - stop('branch-not-found', `ERROR: ${repo} has neither ${target} nor main.`); - } - - // Falling back to main is only safe if main really is the line this version belongs - // to - otherwise we would bump an unrelated major.minor to these versions. - const expected = parts.slice(0, 2).join('.'); - let pom; - try { - const b64 = execFileSync('gh', ['api', - `repos/${repo}/contents/pom.xml?ref=main`, '--jq', '.content'], - { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); - pom = Buffer.from(b64.replace(/\s/g, ''), 'base64').toString('utf8'); - } catch (err) { - stop('branch-not-found', `ERROR: no ${target} branch and could not read pom.xml on main.`); - } - - // The root is the project's own; fall back to when the - // root pom inherits it. - const withoutParent = pom.replace(/[\s\S]*?<\/parent>/, ''); - let m = withoutParent.match(/([^<]+)<\/version>/); - if (!m) { - const parent = pom.match(/[\s\S]*?<\/parent>/); - if (parent) m = parent[0].match(/([^<]+)<\/version>/); - } - const pomVersion = m ? m[1].trim() : ''; - - if (!pomVersion.startsWith(`${expected}.`)) { - stop('version-mismatch', - `ERROR: ${repo} has no ${target} branch, and main is at '${pomVersion}', which is ` + - `not on the ${expected} line. Refusing to bump main to ${version}.`); - } - - console.log(`No ${target} branch; main is at ${pomVersion} - using main.`); - emit('status', 'ok'); - emit('branch', 'main'); - JSEOF + uses: ./.github/actions/resolve-release-branch + with: + repo: ${{ matrix.repo }} + version: ${{ matrix.version }} + commercial: ${{ needs.setup.outputs.commercial }} + token: ${{ inputs.token || secrets.GH_ACTIONS_REPO_TOKEN }} - name: Clone project id: clone diff --git a/docs/release-automation.md b/docs/release-automation.md index f104f67..909bc77 100644 --- a/docs/release-automation.md +++ b/docs/release-automation.md @@ -46,7 +46,7 @@ this document: | | **OSS** | **Commercial** | **Hotfix** | |---|---|---|---| | **Entry point** | [`create-oss-release-branch.yml`](../.github/workflows/create-oss-release-branch.yml) | [`create-commercial-release-branch.yml`](../.github/workflows/create-commercial-release-branch.yml) | [`create-hotfix-release-branch.yml`](../.github/workflows/README-create-hotfix-branch.md) | -| **Cut from** | an OSS branch (`main`, `5.0.x`) | a commercial branch (`3.3.x`) | an OSS **tag** (`v5.0.1`) | +| **Cut from** | an OSS branch (`main`, `5.0.x`), derived from the train | a commercial branch (`3.3.x`) | an OSS **tag** (`v5.0.1`) | | **Via** | a long-lived `..x-internal` branch, full OSS history | nothing — same repo already | an orphan branch, no history | | **Release branch** | `release/5.0.0` | `release/3.3.1` | `release/5.0.1.1` — always a `.1` suffix | | **Published to** | Maven Central | Spring Enterprise | Spring Enterprise | @@ -54,6 +54,43 @@ this document: | **Tag + milestone** | **OSS** repo | **commercial** repo | **commercial** repo | | **`post-release`** | all nine steps | nine, with `commercial: true` | steps 1, 2, 6, 7 only | +### Milestones and release candidates cut across all three + +A release type says *where* a release is built and published. It is a separate question +whether that release is a milestone, a release candidate or a GA, and an OSS train is all +three in turn: `2026.0.0-M1`, `-M2`, `-RC1`, then `2026.0.0`. + +The entry points above are mostly unchanged for a pre-release, with one difference: the +release branch and the milestone carry the phase, so `create-oss-release-branch` cuts +`release/5.1.0-M1` and opens a `5.1.0-M1` milestone rather than `5.1.0`. + +The phase comes from the `spring_cloud_release_train` input, because nothing else can +supply it. When the release branch is cut every version in the tree is still +`-INTERNAL-SNAPSHOT` or `-SNAPSHOT`, so the properties file content is identical whether +this branch is about to become M1, RC1 or the GA. Passing `2026.0.0-M1` is what makes it a +milestone — the same string you pass to `release-train-ready` and to `post-release`, so one +train version means the same thing at all three steps. + +The phase is stripped again before the internal properties file is looked up, so every +pre-release of a train reads one `2026_0_0-internal-snapshot.properties`. The internal +branch stays at `5.1.0-INTERNAL-SNAPSHOT` from M1 through to GA, so per-phase copies of +that file would be byte-identical. + +The result is one name through the whole chain: the release properties file says +`spring-cloud-config=5.1.0-M1`, the release train checks out `release/5.1.0-M1`, and +`post-release` merges `release/5.1.0-M1` back and closes the `5.1.0-M1` milestone. + +Two further things downstream differ: + +- [`verify-no-snapshot-versions`](../.github/actions/verify-no-snapshot-versions/README.md) + is passed `allow-prerelease`, because a milestone build legitimately carries a mixture of + `-M`, `-RC` and GA versions. `-SNAPSHOT` is still rejected. +- [`post-release`](../.github/workflows/README-post-release.md) takes a `promote_to` input + and skips the parts that assume the train has moved on. It has not: the maintenance + branch stays on `-SNAPSHOT` from M1 all the way to GA, so there is no next + snapshot properties file and no version bump — though the merge back still happens. See + [Milestone and release candidate releases](../.github/workflows/README-post-release.md#milestone-and-release-candidate-releases). + All three converge on the same machinery once the branch exists: `release-train-join` is dispatched, [`spring-release-train-project-ready`](../.github/actions/spring-release-train-project-ready/) stamps the final versions, the external release train builds and tags, and @@ -302,7 +339,7 @@ per project. Its nine jobs, in order: over the [`spring-release-train-project-ready`](../.github/actions/spring-release-train-project-ready/) composite action, which for each project: -1. Validate the branch version against the `jenkins-releaser-config` properties file +1. Resolve this project's version from the `jenkins-releaser-config` properties file — that entry names the `release/` branch — and refuse if `v` is already tagged 2. Check out `release/` 3. `update-project-versions` — stamp final, non-SNAPSHOT dependency versions 4. Delete `ci.yml`, `pr.yml`, `ci-release.yml`, `release-ci-settings.xml` from the release branch @@ -390,9 +427,8 @@ it is now a constant in the dispatch, not a derived value. |---|---|---| | `project` | yes | Either `spring-cloud-config` or `spring-cloud-config-commercial` — the suffix is what selects the destination | | `branch` | yes | Source branch (`main`, `4.2.x`) | -| `release-train` | yes | Spring release train to join (e.g. `2026.09`) | +| `release-train` | no | Spring release train to join (e.g. `2026.09`). Supplying it joins that train; leave it empty to prepare the branch without joining | | `token` | no | Falls back to `GH_ACTIONS_REPO_TOKEN` | -| `trigger-release-train-join` | no, default `true` | Uncheck to prepare the branch without joining the train | Available as both `workflow_dispatch` and `workflow_call`. @@ -590,12 +626,11 @@ flowchart TD |---|---|---| | `oss_repo` | yes | e.g. `spring-cloud-stream` — the commercial repo is always this plus `-commercial` | | `oss_tag` | yes | e.g. `v5.0.1` | -| `spring_release_train` | yes | The Spring release train this hotfix joins | +| `spring_release_train` | no | The Spring release train this hotfix joins. Supplying it joins that train; leave it empty to prepare the branch without joining | | `project_version` | no | Override the auto-computed `.1-SNAPSHOT` | | `release_train_version` | no | When set, dependency versions are pulled from that Spring Cloud train's properties file | | `versions` | no | JSON map of explicit dependency versions, e.g. `{"spring-boot":"3.3.0"}`. **Mutually exclusive** with `release_train_version` | | `sha` | no | Commit of *this* repo to copy release-train action files from | -| `trigger_release_train_join` | no, default `true` | Uncheck to prepare without joining | ### The seven jobs