From edbb9534efc8d4188e0ee6e1dcd5a7139b241ccd Mon Sep 17 00:00:00 2001 From: Ryan Baxter Date: Thu, 15 Oct 2026 16:01:36 -0400 Subject: [PATCH] Implementation of a workflow to mark an entire spring cloud release train ready --- .../__tests__/index.test.js | 47 ++++++++++ .../retire-branch-projects-json/dist/index.js | 69 +++++++++----- .../retire-branch-projects-json/src/index.js | 77 +++++++++------ .../__tests__/release-ready-plan.test.js | 84 +++++++++++++++++ .github/scripts/release-branch-plan.js | 4 +- .github/scripts/release-ready-plan.js | 93 +++++++++++++++++++ .../README-mark-release-train-ready.md | 37 ++++++++ .github/workflows/README-post-release.md | 2 +- .../workflows/mark-release-train-ready.yml | 73 +++++++++++++++ ...ease-train-ready.yml => project-ready.yml} | 6 +- docs/release-automation.md | 10 +- 11 files changed, 446 insertions(+), 56 deletions(-) create mode 100644 .github/scripts/__tests__/release-ready-plan.test.js create mode 100644 .github/scripts/release-ready-plan.js create mode 100644 .github/workflows/README-mark-release-train-ready.md create mode 100644 .github/workflows/mark-release-train-ready.yml rename .github/workflows/{release-train-ready.yml => project-ready.yml} (93%) diff --git a/.github/actions/retire-branch-projects-json/__tests__/index.test.js b/.github/actions/retire-branch-projects-json/__tests__/index.test.js index f690afb..b1625c7 100644 --- a/.github/actions/retire-branch-projects-json/__tests__/index.test.js +++ b/.github/actions/retire-branch-projects-json/__tests__/index.test.js @@ -117,3 +117,50 @@ describe('retireBranch', () => { expect(data['spring-cloud-config'].oss.jdkVersions['4.1.x']).toBeUndefined(); }); }); + +// ── run: push retry ────────────────────────────────────────────────────────── + +describe('run push retry', () => { + const fs = require('fs'); + const exec = require('@actions/exec'); + const { run } = require('../src/index'); + + const projects = () => JSON.stringify({ + 'spring-cloud-config': { + commercial: { branches: { scheduled: ['release/5.0.3'], default: ['5.0.x'] }, jdkVersions: {} }, + }, + }); + + beforeEach(() => { + process.env.GITHUB_REPOSITORY = 'spring-cloud/spring-cloud-github-actions'; + core.getInput.mockImplementation(name => ({ + repo: 'spring-cloud/spring-cloud-config-commercial', branch: 'release/5.0.3', token: 't', + }[name])); + jest.spyOn(fs, 'existsSync').mockReturnValue(false); + jest.spyOn(fs, 'readFileSync').mockImplementation(projects); + jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {}); + jest.spyOn(global, 'setTimeout').mockImplementation(fn => { fn(); return 0; }); + }); + afterEach(() => jest.restoreAllMocks()); + + test('reapplies the change on the new main after a rejected push', async () => { + let pushes = 0; + exec.exec.mockImplementation(async (cmd, args) => { + if (args.includes('diff')) return 1; // something staged + if (args.includes('push')) return ++pushes === 1 ? 1 : 0; + return 0; + }); + await run(); + expect(pushes).toBe(2); + expect(core.setFailed).not.toHaveBeenCalled(); + const calls = exec.exec.mock.calls.map(c => c[1].join(' ')); + expect(calls.some(c => c.includes('reset --hard origin/main'))).toBe(true); + expect(calls.filter(c => c.includes(' commit ')).length).toBe(2); + }); + + test('fails after repeated rejections', async () => { + exec.exec.mockImplementation(async (cmd, args) => (args.includes('push') || args.includes('diff') ? 1 : 0)); + await run(); + expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining('rejected 5 times')); + }); +}); diff --git a/.github/actions/retire-branch-projects-json/dist/index.js b/.github/actions/retire-branch-projects-json/dist/index.js index 35f9a34..abd5b42 100644 --- a/.github/actions/retire-branch-projects-json/dist/index.js +++ b/.github/actions/retire-branch-projects-json/dist/index.js @@ -25669,6 +25669,8 @@ function dumpsPretty(data) { }); } +const MAX_PUSH_ATTEMPTS = 5; + // ── Core retire logic ─────────────────────────────────────────────────────── /** @@ -25777,33 +25779,56 @@ async function run() { await exec.exec('git', ['-C', repoDir, 'config', 'user.email', 'svc.spring-builds@broadcom.com']); const projectsFile = path.join(repoDir, 'config', 'projects.json'); - const data = JSON.parse(fs.readFileSync(projectsFile, 'utf-8')); - const changed = retireBranch(data, repo, branch); + // Several runs can update projects.json at once (mark-release-train-ready dispatches one + // per project), so a push can be rejected because main moved after the clone. On a + // rejection, reset to the new main and apply the change again to the fresh file rather + // than rebasing: two runs touching neighbouring lines of the JSON would conflict as text + // even though their changes don't overlap. + for (let attempt = 1; ; attempt++) { + const data = JSON.parse(fs.readFileSync(projectsFile, 'utf-8')); - if (!changed) { - core.info('No changes required.'); - return; - } + const changed = retireBranch(data, repo, branch); - fs.writeFileSync(projectsFile, dumpsPretty(data) + '\n', 'utf-8'); + if (!changed) { + core.info('No changes required.'); + return; + } - await exec.exec('git', ['-C', repoDir, 'add', 'config/projects.json']); + fs.writeFileSync(projectsFile, dumpsPretty(data) + '\n', 'utf-8'); - const diffCode = await exec.exec( - 'git', ['-C', repoDir, 'diff', '--cached', '--quiet'], - { ignoreReturnCode: true } - ); - if (diffCode === 0) { - core.info('projects.json is unchanged — nothing to commit.'); - return; - } + await exec.exec('git', ['-C', repoDir, 'add', 'config/projects.json']); - await exec.exec('git', [ - '-C', repoDir, 'commit', '-m', - `Update projects.json: retire ${baseName} branch ${branch}`, - ]); - await exec.exec('git', ['-C', repoDir, 'push', 'origin', 'main']); + const diffCode = await exec.exec( + 'git', ['-C', repoDir, 'diff', '--cached', '--quiet'], + { ignoreReturnCode: true } + ); + if (diffCode === 0) { + core.info('projects.json is unchanged — nothing to commit.'); + return; + } + + await exec.exec('git', [ + '-C', repoDir, 'commit', '-m', + `Update projects.json: retire ${baseName} branch ${branch}`, + ]); + const pushCode = await exec.exec( + 'git', ['-C', repoDir, 'push', 'origin', 'main'], + { ignoreReturnCode: true } + ); + if (pushCode === 0) break; + + if (attempt >= MAX_PUSH_ATTEMPTS) { + throw new Error(`push to main was rejected ${MAX_PUSH_ATTEMPTS} times`); + } + // Jittered, so runs that collided once don't collide again in lockstep. + const delayMs = 2000 * attempt + Math.floor(Math.random() * 3000); + core.warning(`Push to main rejected (attempt ${attempt}/${MAX_PUSH_ATTEMPTS}); ` + + `retrying on the latest main in ${Math.round(delayMs / 1000)}s.`); + await new Promise(resolve => setTimeout(resolve, delayMs)); + await exec.exec('git', ['-C', repoDir, 'fetch', 'origin', 'main']); + await exec.exec('git', ['-C', repoDir, 'reset', '--hard', 'origin/main']); + } core.info('projects.json committed and pushed.'); } catch (err) { @@ -25811,7 +25836,7 @@ async function run() { } } -module.exports = { dumpsPretty, retireBranch }; +module.exports = { dumpsPretty, retireBranch, run }; if (require.main === require.cache[eval('__filename')]) { run(); diff --git a/.github/actions/retire-branch-projects-json/src/index.js b/.github/actions/retire-branch-projects-json/src/index.js index 2e37322..05611c7 100644 --- a/.github/actions/retire-branch-projects-json/src/index.js +++ b/.github/actions/retire-branch-projects-json/src/index.js @@ -20,6 +20,8 @@ function dumpsPretty(data) { }); } +const MAX_PUSH_ATTEMPTS = 5; + // ── Core retire logic ─────────────────────────────────────────────────────── /** @@ -128,41 +130,64 @@ async function run() { await exec.exec('git', ['-C', repoDir, 'config', 'user.email', 'svc.spring-builds@broadcom.com']); const projectsFile = path.join(repoDir, 'config', 'projects.json'); - const data = JSON.parse(fs.readFileSync(projectsFile, 'utf-8')); - - const changed = retireBranch(data, repo, branch); - - if (!changed) { - core.info('No changes required.'); - return; - } - - fs.writeFileSync(projectsFile, dumpsPretty(data) + '\n', 'utf-8'); - await exec.exec('git', ['-C', repoDir, 'add', 'config/projects.json']); - - const diffCode = await exec.exec( - 'git', ['-C', repoDir, 'diff', '--cached', '--quiet'], - { ignoreReturnCode: true } - ); - if (diffCode === 0) { - core.info('projects.json is unchanged — nothing to commit.'); - return; + // Several runs can update projects.json at once (mark-release-train-ready dispatches one + // per project), so a push can be rejected because main moved after the clone. On a + // rejection, reset to the new main and apply the change again to the fresh file rather + // than rebasing: two runs touching neighbouring lines of the JSON would conflict as text + // even though their changes don't overlap. + for (let attempt = 1; ; attempt++) { + const data = JSON.parse(fs.readFileSync(projectsFile, 'utf-8')); + + const changed = retireBranch(data, repo, branch); + + if (!changed) { + core.info('No changes required.'); + return; + } + + fs.writeFileSync(projectsFile, dumpsPretty(data) + '\n', 'utf-8'); + + await exec.exec('git', ['-C', repoDir, 'add', 'config/projects.json']); + + const diffCode = await exec.exec( + 'git', ['-C', repoDir, 'diff', '--cached', '--quiet'], + { ignoreReturnCode: true } + ); + if (diffCode === 0) { + core.info('projects.json is unchanged — nothing to commit.'); + return; + } + + await exec.exec('git', [ + '-C', repoDir, 'commit', '-m', + `Update projects.json: retire ${baseName} branch ${branch}`, + ]); + const pushCode = await exec.exec( + 'git', ['-C', repoDir, 'push', 'origin', 'main'], + { ignoreReturnCode: true } + ); + if (pushCode === 0) break; + + if (attempt >= MAX_PUSH_ATTEMPTS) { + throw new Error(`push to main was rejected ${MAX_PUSH_ATTEMPTS} times`); + } + // Jittered, so runs that collided once don't collide again in lockstep. + const delayMs = 2000 * attempt + Math.floor(Math.random() * 3000); + core.warning(`Push to main rejected (attempt ${attempt}/${MAX_PUSH_ATTEMPTS}); ` + + `retrying on the latest main in ${Math.round(delayMs / 1000)}s.`); + await new Promise(resolve => setTimeout(resolve, delayMs)); + await exec.exec('git', ['-C', repoDir, 'fetch', 'origin', 'main']); + await exec.exec('git', ['-C', repoDir, 'reset', '--hard', 'origin/main']); } - await exec.exec('git', [ - '-C', repoDir, 'commit', '-m', - `Update projects.json: retire ${baseName} branch ${branch}`, - ]); - await exec.exec('git', ['-C', repoDir, 'push', 'origin', 'main']); - core.info('projects.json committed and pushed.'); } catch (err) { core.setFailed(`Action failed: ${err.message}`); } } -module.exports = { dumpsPretty, retireBranch }; +module.exports = { dumpsPretty, retireBranch, run }; if (require.main === module) { run(); diff --git a/.github/scripts/__tests__/release-ready-plan.test.js b/.github/scripts/__tests__/release-ready-plan.test.js new file mode 100644 index 0000000..23d90dc --- /dev/null +++ b/.github/scripts/__tests__/release-ready-plan.test.js @@ -0,0 +1,84 @@ +'use strict'; + +const { buildPlan, summarize } = require('../release-ready-plan'); + +const entries = list => list.map(([key, version]) => ({ key, version })); +const branches = (...list) => (repo, branch) => list.includes(`${repo}:${branch}`); +const tags = (...list) => (repo, tag) => list.includes(`${repo}:${tag}`); +const noTags = tags(); + +describe('buildPlan', () => { + it('plans a project whose release branch exists and is untagged', () => { + const { todo, skipped } = buildPlan({ + entries: entries([['spring-cloud-config', '5.0.3-SNAPSHOT']]), + branchExists: branches('spring-cloud/spring-cloud-config-commercial:release/5.0.3'), + tagExists: noTags, + }); + expect(todo).toEqual([{ project: 'spring-cloud-config', version: '5.0.3', branch: 'release/5.0.3' }]); + expect(skipped).toEqual([]); + }); + + it('keeps a pre-release version as is', () => { + const { todo } = buildPlan({ + entries: entries([['spring-cloud-config', '5.1.0-M1']]), + branchExists: branches('spring-cloud/spring-cloud-config-commercial:release/5.1.0-M1'), + tagExists: noTags, + }); + expect(todo[0].branch).toBe('release/5.1.0-M1'); + }); + + it('plans a hotfix version', () => { + const { todo } = buildPlan({ + entries: entries([['spring-cloud-vault', '5.0.3.1-SNAPSHOT']]), + branchExists: branches('spring-cloud/spring-cloud-vault-commercial:release/5.0.3.1'), + tagExists: noTags, + }); + expect(todo[0].branch).toBe('release/5.0.3.1'); + }); + + it('skips a version already tagged in either repo', () => { + for (const repo of ['spring-cloud/spring-cloud-config', 'spring-cloud/spring-cloud-config-commercial']) { + const { todo, skipped } = buildPlan({ + entries: entries([['spring-cloud-config', '5.0.3']]), + branchExists: () => true, + tagExists: tags(`${repo}:v5.0.3`), + }); + expect(todo).toEqual([]); + expect(skipped[0].reason).toMatch(/already released \(tag v5\.0\.3 in /); + } + }); + + it('skips a project with no release branch', () => { + const { todo, skipped } = buildPlan({ + entries: entries([['spring-cloud-config', '5.0.3']]), + branchExists: () => false, + tagExists: noTags, + }); + expect(todo).toEqual([]); + expect(skipped[0].reason).toMatch(/no release branch/); + }); + + it('skips non-project keys and unusable versions', () => { + const { todo, skipped } = buildPlan({ + entries: entries([['spring-boot', '4.0.0'], ['spring-cloud-config', 'oops']]), + branchExists: () => true, + tagExists: noTags, + }); + expect(todo).toEqual([]); + expect(skipped).toHaveLength(1); + expect(skipped[0].reason).toMatch(/unusable version/); + }); +}); + +describe('summarize', () => { + it('lists both sections', () => { + const out = summarize({ + train: '2026.1.0', file: 'f.properties', + todo: [{ project: 'a', branch: 'release/1.0.0' }], + skipped: [{ project: 'b', branch: 'release/2.0.0', reason: 'why' }], + }); + expect(out).toContain('**To mark ready (1)**'); + expect(out).toContain('- a -> `release/1.0.0`'); + expect(out).toContain('- b (`release/2.0.0`): why'); + }); +}); diff --git a/.github/scripts/release-branch-plan.js b/.github/scripts/release-branch-plan.js index 706efc8..c000bd0 100644 --- a/.github/scripts/release-branch-plan.js +++ b/.github/scripts/release-branch-plan.js @@ -150,7 +150,9 @@ const summarize = ({ mode, train, file, todo, skipped }) => { return lines.join('\n'); }; -module.exports = { MODES, NON_PROJECT_KEYS, parseTrain, branchFor, buildPlan, summarize }; +module.exports = { + MODES, NON_PROJECT_KEYS, ORG, parseTrain, branchFor, buildPlan, summarize, ghBranchExists, ghTagExists, +}; // CLI: node release-branch-plan.js // diff --git a/.github/scripts/release-ready-plan.js b/.github/scripts/release-ready-plan.js new file mode 100644 index 0000000..90ca51f --- /dev/null +++ b/.github/scripts/release-ready-plan.js @@ -0,0 +1,93 @@ +'use strict'; + +// Works out which projects in a release train have a release branch that is ready to be +// marked ready, for mark-release-train-ready.yml. +// +// The mirror image of release-branch-plan.js: that one wants the branch to be missing, this +// one wants it to be there. Release branches only exist in the -commercial repos, so +// there is no mode - the train's properties file is read as given, the branch is +// release/ with any -SNAPSHOT taken off, and a project goes into the matrix when +// +// - the version has not been released (no v tag in the OSS or commercial repo), and +// - release/ exists in -commercial. +// +// A project the train is not releasing still has an entry, naming its previous release. That +// release is tagged, or its branch is gone, so the same two checks leave it out. + +const fs = require('fs'); +const { fetchReleaserConfig } = require('./releaser-config'); +const { NON_PROJECT_KEYS, ORG, ghBranchExists, ghTagExists } = require('./release-branch-plan'); + +// Pure: entries in, plan out. `branchExists(repo, branch)` and `tagExists(repo, tag)` are +// injected so tests need no network. +const buildPlan = ({ entries, branchExists, tagExists }) => { + const todo = []; + const skipped = []; + for (const { key, version: raw } of entries) { + if (NON_PROJECT_KEYS.has(key)) continue; + const version = raw.replace(/-SNAPSHOT$/i, ''); + if (!/^\d+\.\d+/.test(version)) { + skipped.push({ project: key, reason: `unusable version '${raw}'` }); + continue; + } + const branch = `release/${version}`; + const ossRepo = `${ORG}/${key}`; + const commercialRepo = `${ossRepo}-commercial`; + const tag = `v${version}`; + const taggedIn = [ossRepo, commercialRepo].find(repo => tagExists(repo, tag)); + if (taggedIn) { + skipped.push({ project: key, branch, reason: `already released (tag ${tag} in ${taggedIn})` }); + } else if (!branchExists(commercialRepo, branch)) { + skipped.push({ project: key, branch, reason: `no release branch in ${key}-commercial` }); + } else { + todo.push({ project: key, version, branch }); + } + } + return { todo, skipped }; +}; + +const summarize = ({ train, file, todo, skipped }) => [ + `### project-ready - ${train}`, + '', + `Releaser config: \`${file}\``, + '', + `**To mark ready (${todo.length})**`, + '', + ...(todo.length ? todo.map(t => `- ${t.project} -> \`${t.branch}\``) : ['- none']), + '', + `**Skipped (${skipped.length})**`, + '', + ...(skipped.length + ? skipped.map(s => `- ${s.project}${s.branch ? ` (\`${s.branch}\`)` : ''}: ${s.reason}`) + : ['- none']), + '', +].join('\n'); + +module.exports = { buildPlan, summarize }; + +// CLI: node release-ready-plan.js +// +// Writes `matrix` (a {include:[...]} object) and `has_work` to $GITHUB_OUTPUT and a report to +// $GITHUB_STEP_SUMMARY, when those are set. +if (require.main === module) { + const [train] = process.argv.slice(2); + if (!train) { + process.stderr.write('usage: release-ready-plan.js \n'); + process.exit(2); + } + try { + const { file, entries } = fetchReleaserConfig(train.trim()); + const plan = buildPlan({ entries, branchExists: ghBranchExists, tagExists: ghTagExists }); + const report = summarize({ train, file, ...plan }); + + process.stdout.write(report + '\n'); + if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, report + '\n'); + if (process.env.GITHUB_OUTPUT) { + fs.appendFileSync(process.env.GITHUB_OUTPUT, + `matrix=${JSON.stringify({ include: plan.todo })}\nhas_work=${plan.todo.length > 0}\n`); + } + } catch (err) { + process.stderr.write(`${err.message}\n`); + process.exit(1); + } +} diff --git a/.github/workflows/README-mark-release-train-ready.md b/.github/workflows/README-mark-release-train-ready.md new file mode 100644 index 0000000..3ab9148 --- /dev/null +++ b/.github/workflows/README-mark-release-train-ready.md @@ -0,0 +1,37 @@ +# Mark Release Train Ready + +Runs [`project-ready.yml`](project-ready.yml) for every project in a release train +whose release branch exists and has not been released yet. It is the counterpart of +[Create Release Branches](README-create-release-branches.md), with the check inverted: that +workflow acts on projects whose branch is missing, this one on projects whose branch is there. + +There is no mode. Release branches only exist in the `-commercial` repos, and +`project-ready.yml` only operates on them. + +## Inputs + +| Input | Notes | +|---|---| +| `spring_cloud_release_train` | The train, as named by the properties file: `2026.1.0`, `2026.0.0-RC1`, `2025.1.4.1`. | +| `spring_release_train` | The Spring train every project joins, e.g. `2026.1`. | + +## How it works + +1. **plan** reads the train's properties file from + `spring-cloud-release-commercial@jenkins-releaser-config` (`.github/scripts/release-ready-plan.js`). + For each project the version has `-SNAPSHOT` stripped and the branch is `release/`. + A project is planned when no `v` tag exists in its OSS or commercial repo and the + branch exists in `-commercial`. The rest are listed under *Skipped* in the run summary. +2. **ready** dispatches `project-ready.yml` for each planned project + (`project=-commercial`) and waits for it, 3 at a time. A failing child fails the + run but does not stop the others. Re-running only picks up what is left. + +The properties file is read as given, with no `-INTERNAL-SNAPSHOT` filter, so a project that +the train is not releasing is left out only because its entry names a release that is already +tagged or whose branch is gone. Check the *Skipped* list on an unfamiliar train. + +## Concurrent `projects.json` updates + +Each child ends by retiring its branch from `config/projects.json`, so the children push to +`main` at the same time. `retire-branch-projects-json` re-applies its change on the latest +`main` and retries when a push is rejected, as `update-projects-json` does. diff --git a/.github/workflows/README-post-release.md b/.github/workflows/README-post-release.md index b4ec640..fa5f91e 100644 --- a/.github/workflows/README-post-release.md +++ b/.github/workflows/README-post-release.md @@ -726,5 +726,5 @@ Notes on this: ## Related workflows -- [release-train-ready.yml](release-train-ready.yml) — marks a single project ready in a Spring release train, reading the same `jenkins-releaser-config` properties files +- [project-ready.yml](project-ready.yml) — marks a single project ready in a Spring release train, reading the same `jenkins-releaser-config` properties files - [create-hotfix-release-branch.yml](README-create-hotfix-branch.md) — creates the commercial `release/` branches this workflow later merges back diff --git a/.github/workflows/mark-release-train-ready.yml b/.github/workflows/mark-release-train-ready.yml new file mode 100644 index 0000000..bddfb30 --- /dev/null +++ b/.github/workflows/mark-release-train-ready.yml @@ -0,0 +1,73 @@ +name: Mark Release Train Ready + +run-name: "Mark Release Train Ready - ${{ inputs.spring_cloud_release_train }}" + +# Runs project-ready.yml for every project in a release train whose release branch +# exists and has not been released yet. +# +# The projects and their versions come from the train's jenkins-releaser-config properties +# file (spring-cloud-release-commercial@jenkins-releaser-config), read as given. For each +# project release/ is looked up in -commercial; projects that are already +# tagged, or have no release branch, are skipped, so re-running after a partial failure only +# picks up what is left. The plan logic is .github/scripts/release-ready-plan.js. +# +# Each child run is dispatched and waited on. A failing child fails this run, but does not +# stop the others. + +on: + workflow_dispatch: + inputs: + spring_cloud_release_train: + description: 'Spring Cloud release train version, as named by the properties file (e.g. 2026.1.0, 2026.0.0-M1 or 2025.1.2.1)' + required: true + type: string + spring_release_train: + description: 'Spring release train every project joins (e.g. 2026.1)' + required: true + type: string + +permissions: + contents: read + +jobs: + plan: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.plan.outputs.matrix }} + has_work: ${{ steps.plan.outputs.has_work }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Work out which projects are ready to be marked ready + id: plan + env: + GH_TOKEN: ${{ secrets.GH_ACTIONS_REPO_TOKEN }} + TRAIN: ${{ inputs.spring_cloud_release_train }} + run: node .github/scripts/release-ready-plan.js "$TRAIN" + + ready: + name: ${{ matrix.project }} (${{ matrix.branch }}) + needs: plan + if: ${{ needs.plan.outputs.has_work == 'true' }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + max-parallel: 3 + matrix: ${{ fromJSON(needs.plan.outputs.matrix) }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Dispatch project-ready and wait + uses: ./.github/actions/dispatch-and-wait + with: + repo: ${{ github.repository }} + workflow: project-ready.yml + ref: ${{ github.ref_name }} + inputs: | + project=${{ matrix.project }}-commercial + spring-cloud-release-train-version=${{ inputs.spring_cloud_release_train }} + spring-release-train-version=${{ inputs.spring_release_train }} + summary-title: ${{ matrix.project }} + token: ${{ secrets.GH_ACTIONS_REPO_TOKEN }} diff --git a/.github/workflows/release-train-ready.yml b/.github/workflows/project-ready.yml similarity index 93% rename from .github/workflows/release-train-ready.yml rename to .github/workflows/project-ready.yml index 712ebbf..8b844ed 100644 --- a/.github/workflows/release-train-ready.yml +++ b/.github/workflows/project-ready.yml @@ -1,4 +1,4 @@ -name: Release Train Ready +name: Project Ready on: workflow_dispatch: @@ -44,8 +44,8 @@ permissions: contents: read jobs: - release-train-ready: - name: Release Train Ready - ${{ inputs.project }} ${{ inputs.spring-cloud-release-train-version }} + project-ready: + name: Project Ready - ${{ inputs.project }} ${{ inputs.spring-cloud-release-train-version }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/docs/release-automation.md b/docs/release-automation.md index be96f32..a29ee95 100644 --- a/docs/release-automation.md +++ b/docs/release-automation.md @@ -69,7 +69,7 @@ The phase comes from the `spring_cloud_release_train` input, because nothing els 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 +milestone — the same string you pass to `project-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 @@ -340,7 +340,7 @@ per project. Its nine jobs, in order: ### Phase 2 — Per-project readiness -**[`release-train-ready.yml`](../.github/workflows/release-train-ready.yml)** is a thin wrapper +**[`project-ready.yml`](../.github/workflows/project-ready.yml)** is a thin wrapper over the [`spring-release-train-project-ready`](../.github/actions/spring-release-train-project-ready/) composite action, which for each project: @@ -354,6 +354,10 @@ composite action, which for each project: 7. Dispatch the project's own generated `release-train-ready.yml` and wait 8. Remove the release branch from the Antora playbook via [`update-antora-playbook`](../.github/actions/update-antora-playbook/) +To run it for every project in a train at once, use +**[`mark-release-train-ready.yml`](../.github/workflows/mark-release-train-ready.yml)** +([README](../.github/workflows/README-mark-release-train-ready.md)). + ### Phase 3 — The meta-release (external) `spring-io/release-train` drives the dependency-ordered meta-release across every project — @@ -770,7 +774,7 @@ The `initialize` job pulls in a second workflow, which runs nine actions of its [`update-projects-json`](../.github/actions/update-projects-json/). That chain is why a hotfix branch arrives fully converted to commercial form despite being cut from an OSS tag. -### `release-train-ready.yml` +### `project-ready.yml` One action does everything: [`spring-release-train-project-ready`](../.github/actions/spring-release-train-project-ready/), which is itself a