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
129 changes: 110 additions & 19 deletions .github/scripts/verify-release-readiness.js
Original file line number Diff line number Diff line change
@@ -1,33 +1,124 @@
#!/usr/bin/env node

const { execFileSync } = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');

const REPORT = 'docs/performance/results/order-scale-language-summary.json';
const expectedVersion = process.env.RELEASE_VERSION;
const CZ_TOML = '.cz.toml';
const RC_VERSION = /^\d+\.\d+\.\d+-rc\.\d+$/;

if (!expectedVersion) {
throw new Error('RELEASE_VERSION is required');
function readVersion(content) {
const match = content.match(/^version\s*=\s*"([^"]+)"/m);
if (!match) {
throw new Error(`Could not find a version in ${CZ_TOML}`);
}
return match[1];
}

const summary = JSON.parse(fs.readFileSync(REPORT, 'utf8'));
const candidate = summary.candidate || {};
const expectedCoordinate = `blue.language:blue-language-java:${expectedVersion}`;
const failures = [];
function validateReleaseReadiness({
expectedVersion,
currentVersion,
releaseChannel,
existingTag,
changedPaths = [],
githubActions = false,
githubRef,
}) {
const failures = [];

if (candidate.coordinate !== expectedCoordinate) {
failures.push(`candidate coordinate must be ${expectedCoordinate}`);
if (!expectedVersion) {
failures.push('RELEASE_VERSION is required');
} else if (!RC_VERSION.test(expectedVersion)) {
failures.push(`RELEASE_VERSION must match x.y.z-rc.n, found '${expectedVersion}'`);
}

if (releaseChannel !== 'rc') {
failures.push(`BLUE_RELEASE_CHANNEL must be 'rc', found '${releaseChannel || ''}'`);
}

if (expectedVersion && currentVersion !== expectedVersion) {
failures.push(
`${CZ_TOML} version must be '${expectedVersion}', found '${currentVersion || ''}'`,
);
}

if (existingTag) {
failures.push(`release tag '${existingTag}' already exists`);
}

const unexpectedPaths = changedPaths.filter((path) => path && path !== CZ_TOML);
if (unexpectedPaths.length > 0) {
failures.push(
`RC preparation changed files other than ${CZ_TOML}: ${unexpectedPaths.join(', ')}`,
);
}

if (githubActions && githubRef !== 'refs/heads/next') {
failures.push(`RC workflow must run from refs/heads/next, found '${githubRef || ''}'`);
}

return failures;
}

function git(args, cwd) {
return execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
}
if (candidate.releaseReady !== true) {
failures.push('candidate.releaseReady must be true');

function changedTrackedPaths(cwd) {
const status = execFileSync(
'git',
['status', '--porcelain', '--untracked-files=no'],
{ cwd, encoding: 'utf8' },
).replace(/\r?\n$/, '');
if (!status) {
return [];
}
return status.split(/\r?\n/).map((line) => {
const path = line.slice(3).trim();
const renameSeparator = path.lastIndexOf(' -> ');
return renameSeparator >= 0 ? path.slice(renameSeparator + 4) : path;
});
}
if (candidate.verdict !== 'YES') {
failures.push('candidate.verdict must be YES');

function verifyPreparedRelease({ cwd = process.cwd(), env = process.env } = {}) {
const expectedVersion = env.RELEASE_VERSION || '';
const currentVersion = readVersion(fs.readFileSync(path.join(cwd, CZ_TOML), 'utf8'));
const existingTag = expectedVersion
? git(['tag', '--list', `v${expectedVersion}`], cwd)
: '';
const failures = validateReleaseReadiness({
expectedVersion,
currentVersion,
releaseChannel: env.BLUE_RELEASE_CHANNEL,
existingTag,
changedPaths: changedTrackedPaths(cwd),
githubActions: env.GITHUB_ACTIONS === 'true',
githubRef: env.GITHUB_REF,
});

if (failures.length > 0) {
throw new Error(`RC release preflight failed:\n- ${failures.join('\n- ')}`);
}

return expectedVersion;
}

function main() {
try {
const version = verifyPreparedRelease();
console.log(`RC release metadata verified for blue.language:blue-language-java:${version}`);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}

if (failures.length > 0) {
console.error(`Release authorization failed in ${REPORT}:`);
failures.forEach((failure) => console.error(`- ${failure}`));
process.exit(1);
if (require.main === module) {
main();
}

console.log(`Release authorization confirmed for ${expectedCoordinate}`);
module.exports = {
readVersion,
validateReleaseReadiness,
verifyPreparedRelease,
};
110 changes: 110 additions & 0 deletions .github/scripts/verify-release-readiness.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { execFileSync } = require('node:child_process');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');

const {
readVersion,
validateReleaseReadiness,
verifyPreparedRelease,
} = require('./verify-release-readiness');

function validInput(overrides = {}) {
return {
expectedVersion: '3.1.0-rc.16',
currentVersion: '3.1.0-rc.16',
releaseChannel: 'rc',
existingTag: '',
changedPaths: ['.cz.toml'],
githubActions: true,
githubRef: 'refs/heads/next',
...overrides,
};
}

test('reads the Commitizen version', () => {
assert.equal(readVersion('[tool.commitizen]\nversion = "3.1.0-rc.16"\n'), '3.1.0-rc.16');
assert.throws(() => readVersion('[tool.commitizen]\n'), /Could not find a version/);
});

test('accepts a freshly prepared RC on next', () => {
assert.deepEqual(validateReleaseReadiness(validInput()), []);
});

test('rejects missing or malformed release metadata', () => {
assert.match(
validateReleaseReadiness(validInput({ expectedVersion: '' })).join('\n'),
/RELEASE_VERSION is required/,
);
assert.match(
validateReleaseReadiness(validInput({ expectedVersion: '3.1.0' })).join('\n'),
/must match x\.y\.z-rc\.n/,
);
assert.match(
validateReleaseReadiness(validInput({ releaseChannel: 'stable' })).join('\n'),
/BLUE_RELEASE_CHANNEL must be 'rc'/,
);
assert.match(
validateReleaseReadiness(validInput({ currentVersion: '3.1.0-rc.15' })).join('\n'),
/.cz.toml version must be/,
);
});

test('rejects an existing release tag', () => {
assert.match(
validateReleaseReadiness(validInput({ existingTag: 'v3.1.0-rc.16' })).join('\n'),
/already exists/,
);
});

test('rejects preparation side effects outside .cz.toml', () => {
assert.match(
validateReleaseReadiness(
validInput({ changedPaths: ['.cz.toml', 'CHANGELOG.md'] }),
).join('\n'),
/CHANGELOG.md/,
);
});

test('rejects an Actions release from a branch other than next', () => {
assert.match(
validateReleaseReadiness(validInput({ githubRef: 'refs/heads/master' })).join('\n'),
/must run from refs\/heads\/next/,
);
});

test('verifies a prepared RC in a clean temporary repository', () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'blue-rc-readiness-'));
try {
execFileSync('git', ['init'], { cwd: directory });
execFileSync('git', ['config', 'user.name', 'Release Test'], { cwd: directory });
execFileSync('git', ['config', 'user.email', 'release-test@example.invalid'], {
cwd: directory,
});
fs.writeFileSync(
path.join(directory, '.cz.toml'),
'[tool.commitizen]\nversion = "3.1.0-rc.15"\n',
);
execFileSync('git', ['add', '.cz.toml'], { cwd: directory });
execFileSync('git', ['commit', '-m', 'initial'], { cwd: directory });
fs.writeFileSync(
path.join(directory, '.cz.toml'),
'[tool.commitizen]\nversion = "3.1.0-rc.16"\n',
);

assert.equal(
verifyPreparedRelease({
cwd: directory,
env: {
RELEASE_VERSION: '3.1.0-rc.16',
BLUE_RELEASE_CHANNEL: 'rc',
},
}),
'3.1.0-rc.16',
);
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
});
19 changes: 18 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,19 @@ on:
push:
branches:
- master
- next
- 'codex/*'
- 'feature/*'
- 'fix/*'
- 'hotix/*'
- 'hotfix/*'
- 'release/*'
pull_request:
branches:
- master
- next

permissions:
contents: read

jobs:
Build:
Expand All @@ -32,6 +41,14 @@ jobs:
- name: Setup Gradle
uses: gradle/gradle-build-action@v2

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'

- name: Test release tooling
run: node --test .github/scripts/*.test.js

- name: Execute Gradle build
run: ./gradlew clean build

Expand Down
41 changes: 30 additions & 11 deletions .github/workflows/release-rc.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
name: Release RC

on:
workflow_dispatch:
push:
branches:
- next
Expand Down Expand Up @@ -33,8 +34,9 @@ concurrency:

jobs:
ReleaseRC:
if: "${{ startsWith(github.event.head_commit.message, 'chore: release ') == false }}"
if: "${{ github.event_name == 'workflow_dispatch' || startsWith(github.event.head_commit.message, 'chore: release ') == false }}"
runs-on: ubuntu-latest
environment: rc-release
steps:
- name: Check out
uses: actions/checkout@v4
Expand All @@ -60,6 +62,14 @@ jobs:
- name: Setup Gradle
uses: gradle/gradle-build-action@v2

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'

- name: Test release tooling
run: node --test .github/scripts/*.test.js

- name: Configure Git
run: |
git config --global user.email "github-actions[bot]@users.noreply.github.com"
Expand All @@ -69,22 +79,25 @@ jobs:
id: version
run: node .github/scripts/prepare-rc-release.js

- name: Verify release authorization
- name: Verify prepared RC metadata
env:
RELEASE_VERSION: ${{ steps.version.outputs.version }}
run: node .github/scripts/verify-release-readiness.js

- name: Commit and tag RC version
run: |
git add .cz.toml
git commit --allow-empty -m "chore: release ${{ steps.version.outputs.version }}"
git tag -a "v${{ steps.version.outputs.version }}" -m "Release ${{ steps.version.outputs.version }}"
- name: Configure reproducible build timestamp
run: echo "SOURCE_DATE_EPOCH=$(git show -s --format=%ct HEAD)" >> "$GITHUB_ENV"

- name: Execute Gradle build
run: >-
./gradlew clean build identityDifferentialTest
patchSequenceDifferentialTest memoryIntegrationTest cacheLifecycleTest
jmhClasses sourceReleaseArchive
run: ./gradlew clean build rcVerify jmhClasses

- name: Verify reproducible source release
run: |
mapfile -t source_archives < <(find build/release -maxdepth 1 -type f -name '*-source-release.zip')
test "${#source_archives[@]}" -eq 1
first_sha="$(sha256sum "${source_archives[0]}" | awk '{print $1}')"
./gradlew sourceReleaseArchive --rerun-tasks
second_sha="$(sha256sum "${source_archives[0]}" | awk '{print $1}')"
test "$first_sha" = "$second_sha"

- name: Verify binary API compatibility
run: |
Expand Down Expand Up @@ -145,6 +158,12 @@ jobs:
> build/reports/binary-api/previous-rc-baseline.txt
fi

- name: Commit and tag RC version
run: |
git add .cz.toml
git commit -m "chore: release ${{ steps.version.outputs.version }}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the tag-recovery retry path

When a retry starts from next after the branch already contains the prepared .cz.toml version but the tag was not created or pushed (for example, a non-atomic git push ... --follow-tags updated the branch ref and rejected the tag), prepare-rc-release.js computes the same RC version and leaves nothing staged. Because this line no longer permits an empty release commit, the workflow fails here before it can recreate the missing tag; either require/advance a changed .cz.toml or keep the empty-commit fallback for that recovery case.

Useful? React with 👍 / 👎.

git tag -a "v${{ steps.version.outputs.version }}" -m "Release ${{ steps.version.outputs.version }}"

# Publish the unique version reservation before any remote artifact upload.
# A failed release can then advance to a new RC instead of reusing a
# coordinate that a previous attempt may already have uploaded.
Expand Down
Loading