Skip to content

Commit 5074e7f

Browse files
authored
Merge pull request #37 from spring-cloud/spring-cloud-release-train-ready
Implementation of a workflow to mark an entire spring cloud release train ready
2 parents ecaea73 + edbb953 commit 5074e7f

11 files changed

Lines changed: 446 additions & 56 deletions

File tree

‎.github/actions/retire-branch-projects-json/__tests__/index.test.js‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,3 +117,50 @@ describe('retireBranch', () => {
117117
expect(data['spring-cloud-config'].oss.jdkVersions['4.1.x']).toBeUndefined();
118118
});
119119
});
120+
121+
// ── run: push retry ──────────────────────────────────────────────────────────
122+
123+
describe('run push retry', () => {
124+
const fs = require('fs');
125+
const exec = require('@actions/exec');
126+
const { run } = require('../src/index');
127+
128+
const projects = () => JSON.stringify({
129+
'spring-cloud-config': {
130+
commercial: { branches: { scheduled: ['release/5.0.3'], default: ['5.0.x'] }, jdkVersions: {} },
131+
},
132+
});
133+
134+
beforeEach(() => {
135+
process.env.GITHUB_REPOSITORY = 'spring-cloud/spring-cloud-github-actions';
136+
core.getInput.mockImplementation(name => ({
137+
repo: 'spring-cloud/spring-cloud-config-commercial', branch: 'release/5.0.3', token: 't',
138+
}[name]));
139+
jest.spyOn(fs, 'existsSync').mockReturnValue(false);
140+
jest.spyOn(fs, 'readFileSync').mockImplementation(projects);
141+
jest.spyOn(fs, 'writeFileSync').mockImplementation(() => {});
142+
jest.spyOn(global, 'setTimeout').mockImplementation(fn => { fn(); return 0; });
143+
});
144+
afterEach(() => jest.restoreAllMocks());
145+
146+
test('reapplies the change on the new main after a rejected push', async () => {
147+
let pushes = 0;
148+
exec.exec.mockImplementation(async (cmd, args) => {
149+
if (args.includes('diff')) return 1; // something staged
150+
if (args.includes('push')) return ++pushes === 1 ? 1 : 0;
151+
return 0;
152+
});
153+
await run();
154+
expect(pushes).toBe(2);
155+
expect(core.setFailed).not.toHaveBeenCalled();
156+
const calls = exec.exec.mock.calls.map(c => c[1].join(' '));
157+
expect(calls.some(c => c.includes('reset --hard origin/main'))).toBe(true);
158+
expect(calls.filter(c => c.includes(' commit ')).length).toBe(2);
159+
});
160+
161+
test('fails after repeated rejections', async () => {
162+
exec.exec.mockImplementation(async (cmd, args) => (args.includes('push') || args.includes('diff') ? 1 : 0));
163+
await run();
164+
expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining('rejected 5 times'));
165+
});
166+
});

‎.github/actions/retire-branch-projects-json/dist/index.js‎

Lines changed: 47 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -25669,6 +25669,8 @@ function dumpsPretty(data) {
2566925669
});
2567025670
}
2567125671

25672+
const MAX_PUSH_ATTEMPTS = 5;
25673+
2567225674
// ── Core retire logic ───────────────────────────────────────────────────────
2567325675

2567425676
/**
@@ -25777,41 +25779,64 @@ async function run() {
2577725779
await exec.exec('git', ['-C', repoDir, 'config', 'user.email', 'svc.spring-builds@broadcom.com']);
2577825780

2577925781
const projectsFile = path.join(repoDir, 'config', 'projects.json');
25780-
const data = JSON.parse(fs.readFileSync(projectsFile, 'utf-8'));
2578125782

25782-
const changed = retireBranch(data, repo, branch);
25783+
// Several runs can update projects.json at once (mark-release-train-ready dispatches one
25784+
// per project), so a push can be rejected because main moved after the clone. On a
25785+
// rejection, reset to the new main and apply the change again to the fresh file rather
25786+
// than rebasing: two runs touching neighbouring lines of the JSON would conflict as text
25787+
// even though their changes don't overlap.
25788+
for (let attempt = 1; ; attempt++) {
25789+
const data = JSON.parse(fs.readFileSync(projectsFile, 'utf-8'));
2578325790

25784-
if (!changed) {
25785-
core.info('No changes required.');
25786-
return;
25787-
}
25791+
const changed = retireBranch(data, repo, branch);
2578825792

25789-
fs.writeFileSync(projectsFile, dumpsPretty(data) + '\n', 'utf-8');
25793+
if (!changed) {
25794+
core.info('No changes required.');
25795+
return;
25796+
}
2579025797

25791-
await exec.exec('git', ['-C', repoDir, 'add', 'config/projects.json']);
25798+
fs.writeFileSync(projectsFile, dumpsPretty(data) + '\n', 'utf-8');
2579225799

25793-
const diffCode = await exec.exec(
25794-
'git', ['-C', repoDir, 'diff', '--cached', '--quiet'],
25795-
{ ignoreReturnCode: true }
25796-
);
25797-
if (diffCode === 0) {
25798-
core.info('projects.json is unchanged — nothing to commit.');
25799-
return;
25800-
}
25800+
await exec.exec('git', ['-C', repoDir, 'add', 'config/projects.json']);
2580125801

25802-
await exec.exec('git', [
25803-
'-C', repoDir, 'commit', '-m',
25804-
`Update projects.json: retire ${baseName} branch ${branch}`,
25805-
]);
25806-
await exec.exec('git', ['-C', repoDir, 'push', 'origin', 'main']);
25802+
const diffCode = await exec.exec(
25803+
'git', ['-C', repoDir, 'diff', '--cached', '--quiet'],
25804+
{ ignoreReturnCode: true }
25805+
);
25806+
if (diffCode === 0) {
25807+
core.info('projects.json is unchanged — nothing to commit.');
25808+
return;
25809+
}
25810+
25811+
await exec.exec('git', [
25812+
'-C', repoDir, 'commit', '-m',
25813+
`Update projects.json: retire ${baseName} branch ${branch}`,
25814+
]);
25815+
const pushCode = await exec.exec(
25816+
'git', ['-C', repoDir, 'push', 'origin', 'main'],
25817+
{ ignoreReturnCode: true }
25818+
);
25819+
if (pushCode === 0) break;
25820+
25821+
if (attempt >= MAX_PUSH_ATTEMPTS) {
25822+
throw new Error(`push to main was rejected ${MAX_PUSH_ATTEMPTS} times`);
25823+
}
25824+
// Jittered, so runs that collided once don't collide again in lockstep.
25825+
const delayMs = 2000 * attempt + Math.floor(Math.random() * 3000);
25826+
core.warning(`Push to main rejected (attempt ${attempt}/${MAX_PUSH_ATTEMPTS}); ` +
25827+
`retrying on the latest main in ${Math.round(delayMs / 1000)}s.`);
25828+
await new Promise(resolve => setTimeout(resolve, delayMs));
25829+
await exec.exec('git', ['-C', repoDir, 'fetch', 'origin', 'main']);
25830+
await exec.exec('git', ['-C', repoDir, 'reset', '--hard', 'origin/main']);
25831+
}
2580725832

2580825833
core.info('projects.json committed and pushed.');
2580925834
} catch (err) {
2581025835
core.setFailed(`Action failed: ${err.message}`);
2581125836
}
2581225837
}
2581325838

25814-
module.exports = { dumpsPretty, retireBranch };
25839+
module.exports = { dumpsPretty, retireBranch, run };
2581525840

2581625841
if (require.main === require.cache[eval('__filename')]) {
2581725842
run();

‎.github/actions/retire-branch-projects-json/src/index.js‎

Lines changed: 51 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ function dumpsPretty(data) {
2020
});
2121
}
2222

23+
const MAX_PUSH_ATTEMPTS = 5;
24+
2325
// ── Core retire logic ───────────────────────────────────────────────────────
2426

2527
/**
@@ -128,41 +130,64 @@ async function run() {
128130
await exec.exec('git', ['-C', repoDir, 'config', 'user.email', 'svc.spring-builds@broadcom.com']);
129131

130132
const projectsFile = path.join(repoDir, 'config', 'projects.json');
131-
const data = JSON.parse(fs.readFileSync(projectsFile, 'utf-8'));
132-
133-
const changed = retireBranch(data, repo, branch);
134-
135-
if (!changed) {
136-
core.info('No changes required.');
137-
return;
138-
}
139-
140-
fs.writeFileSync(projectsFile, dumpsPretty(data) + '\n', 'utf-8');
141133

142-
await exec.exec('git', ['-C', repoDir, 'add', 'config/projects.json']);
143-
144-
const diffCode = await exec.exec(
145-
'git', ['-C', repoDir, 'diff', '--cached', '--quiet'],
146-
{ ignoreReturnCode: true }
147-
);
148-
if (diffCode === 0) {
149-
core.info('projects.json is unchanged — nothing to commit.');
150-
return;
134+
// Several runs can update projects.json at once (mark-release-train-ready dispatches one
135+
// per project), so a push can be rejected because main moved after the clone. On a
136+
// rejection, reset to the new main and apply the change again to the fresh file rather
137+
// than rebasing: two runs touching neighbouring lines of the JSON would conflict as text
138+
// even though their changes don't overlap.
139+
for (let attempt = 1; ; attempt++) {
140+
const data = JSON.parse(fs.readFileSync(projectsFile, 'utf-8'));
141+
142+
const changed = retireBranch(data, repo, branch);
143+
144+
if (!changed) {
145+
core.info('No changes required.');
146+
return;
147+
}
148+
149+
fs.writeFileSync(projectsFile, dumpsPretty(data) + '\n', 'utf-8');
150+
151+
await exec.exec('git', ['-C', repoDir, 'add', 'config/projects.json']);
152+
153+
const diffCode = await exec.exec(
154+
'git', ['-C', repoDir, 'diff', '--cached', '--quiet'],
155+
{ ignoreReturnCode: true }
156+
);
157+
if (diffCode === 0) {
158+
core.info('projects.json is unchanged — nothing to commit.');
159+
return;
160+
}
161+
162+
await exec.exec('git', [
163+
'-C', repoDir, 'commit', '-m',
164+
`Update projects.json: retire ${baseName} branch ${branch}`,
165+
]);
166+
const pushCode = await exec.exec(
167+
'git', ['-C', repoDir, 'push', 'origin', 'main'],
168+
{ ignoreReturnCode: true }
169+
);
170+
if (pushCode === 0) break;
171+
172+
if (attempt >= MAX_PUSH_ATTEMPTS) {
173+
throw new Error(`push to main was rejected ${MAX_PUSH_ATTEMPTS} times`);
174+
}
175+
// Jittered, so runs that collided once don't collide again in lockstep.
176+
const delayMs = 2000 * attempt + Math.floor(Math.random() * 3000);
177+
core.warning(`Push to main rejected (attempt ${attempt}/${MAX_PUSH_ATTEMPTS}); ` +
178+
`retrying on the latest main in ${Math.round(delayMs / 1000)}s.`);
179+
await new Promise(resolve => setTimeout(resolve, delayMs));
180+
await exec.exec('git', ['-C', repoDir, 'fetch', 'origin', 'main']);
181+
await exec.exec('git', ['-C', repoDir, 'reset', '--hard', 'origin/main']);
151182
}
152183

153-
await exec.exec('git', [
154-
'-C', repoDir, 'commit', '-m',
155-
`Update projects.json: retire ${baseName} branch ${branch}`,
156-
]);
157-
await exec.exec('git', ['-C', repoDir, 'push', 'origin', 'main']);
158-
159184
core.info('projects.json committed and pushed.');
160185
} catch (err) {
161186
core.setFailed(`Action failed: ${err.message}`);
162187
}
163188
}
164189

165-
module.exports = { dumpsPretty, retireBranch };
190+
module.exports = { dumpsPretty, retireBranch, run };
166191

167192
if (require.main === module) {
168193
run();
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
'use strict';
2+
3+
const { buildPlan, summarize } = require('../release-ready-plan');
4+
5+
const entries = list => list.map(([key, version]) => ({ key, version }));
6+
const branches = (...list) => (repo, branch) => list.includes(`${repo}:${branch}`);
7+
const tags = (...list) => (repo, tag) => list.includes(`${repo}:${tag}`);
8+
const noTags = tags();
9+
10+
describe('buildPlan', () => {
11+
it('plans a project whose release branch exists and is untagged', () => {
12+
const { todo, skipped } = buildPlan({
13+
entries: entries([['spring-cloud-config', '5.0.3-SNAPSHOT']]),
14+
branchExists: branches('spring-cloud/spring-cloud-config-commercial:release/5.0.3'),
15+
tagExists: noTags,
16+
});
17+
expect(todo).toEqual([{ project: 'spring-cloud-config', version: '5.0.3', branch: 'release/5.0.3' }]);
18+
expect(skipped).toEqual([]);
19+
});
20+
21+
it('keeps a pre-release version as is', () => {
22+
const { todo } = buildPlan({
23+
entries: entries([['spring-cloud-config', '5.1.0-M1']]),
24+
branchExists: branches('spring-cloud/spring-cloud-config-commercial:release/5.1.0-M1'),
25+
tagExists: noTags,
26+
});
27+
expect(todo[0].branch).toBe('release/5.1.0-M1');
28+
});
29+
30+
it('plans a hotfix version', () => {
31+
const { todo } = buildPlan({
32+
entries: entries([['spring-cloud-vault', '5.0.3.1-SNAPSHOT']]),
33+
branchExists: branches('spring-cloud/spring-cloud-vault-commercial:release/5.0.3.1'),
34+
tagExists: noTags,
35+
});
36+
expect(todo[0].branch).toBe('release/5.0.3.1');
37+
});
38+
39+
it('skips a version already tagged in either repo', () => {
40+
for (const repo of ['spring-cloud/spring-cloud-config', 'spring-cloud/spring-cloud-config-commercial']) {
41+
const { todo, skipped } = buildPlan({
42+
entries: entries([['spring-cloud-config', '5.0.3']]),
43+
branchExists: () => true,
44+
tagExists: tags(`${repo}:v5.0.3`),
45+
});
46+
expect(todo).toEqual([]);
47+
expect(skipped[0].reason).toMatch(/already released \(tag v5\.0\.3 in /);
48+
}
49+
});
50+
51+
it('skips a project with no release branch', () => {
52+
const { todo, skipped } = buildPlan({
53+
entries: entries([['spring-cloud-config', '5.0.3']]),
54+
branchExists: () => false,
55+
tagExists: noTags,
56+
});
57+
expect(todo).toEqual([]);
58+
expect(skipped[0].reason).toMatch(/no release branch/);
59+
});
60+
61+
it('skips non-project keys and unusable versions', () => {
62+
const { todo, skipped } = buildPlan({
63+
entries: entries([['spring-boot', '4.0.0'], ['spring-cloud-config', 'oops']]),
64+
branchExists: () => true,
65+
tagExists: noTags,
66+
});
67+
expect(todo).toEqual([]);
68+
expect(skipped).toHaveLength(1);
69+
expect(skipped[0].reason).toMatch(/unusable version/);
70+
});
71+
});
72+
73+
describe('summarize', () => {
74+
it('lists both sections', () => {
75+
const out = summarize({
76+
train: '2026.1.0', file: 'f.properties',
77+
todo: [{ project: 'a', branch: 'release/1.0.0' }],
78+
skipped: [{ project: 'b', branch: 'release/2.0.0', reason: 'why' }],
79+
});
80+
expect(out).toContain('**To mark ready (1)**');
81+
expect(out).toContain('- a -> `release/1.0.0`');
82+
expect(out).toContain('- b (`release/2.0.0`): why');
83+
});
84+
});

‎.github/scripts/release-branch-plan.js‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,9 @@ const summarize = ({ mode, train, file, todo, skipped }) => {
150150
return lines.join('\n');
151151
};
152152

153-
module.exports = { MODES, NON_PROJECT_KEYS, parseTrain, branchFor, buildPlan, summarize };
153+
module.exports = {
154+
MODES, NON_PROJECT_KEYS, ORG, parseTrain, branchFor, buildPlan, summarize, ghBranchExists, ghTagExists,
155+
};
154156

155157
// CLI: node release-branch-plan.js <mode> <spring-cloud-release-train>
156158
//

0 commit comments

Comments
 (0)