Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
});
});
69 changes: 47 additions & 22 deletions .github/actions/retire-branch-projects-json/dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -25669,6 +25669,8 @@ function dumpsPretty(data) {
});
}

const MAX_PUSH_ATTEMPTS = 5;

// ── Core retire logic ───────────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -25777,41 +25779,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);
// 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) {
core.setFailed(`Action failed: ${err.message}`);
}
}

module.exports = { dumpsPretty, retireBranch };
module.exports = { dumpsPretty, retireBranch, run };

if (require.main === require.cache[eval('__filename')]) {
run();
Expand Down
77 changes: 51 additions & 26 deletions .github/actions/retire-branch-projects-json/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ function dumpsPretty(data) {
});
}

const MAX_PUSH_ATTEMPTS = 5;

// ── Core retire logic ───────────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -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();
Expand Down
84 changes: 84 additions & 0 deletions .github/scripts/__tests__/release-ready-plan.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
4 changes: 3 additions & 1 deletion .github/scripts/release-branch-plan.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <mode> <spring-cloud-release-train>
//
Expand Down
Loading
Loading