diff --git a/.github/scripts/verify-release-readiness.js b/.github/scripts/verify-release-readiness.js index bc95c5b..f90717e 100644 --- a/.github/scripts/verify-release-readiness.js +++ b/.github/scripts/verify-release-readiness.js @@ -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, +}; diff --git a/.github/scripts/verify-release-readiness.test.js b/.github/scripts/verify-release-readiness.test.js new file mode 100644 index 0000000..441e649 --- /dev/null +++ b/.github/scripts/verify-release-readiness.test.js @@ -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 }); + } +}); diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 963cef1..6d16c64 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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: @@ -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 diff --git a/.github/workflows/release-rc.yml b/.github/workflows/release-rc.yml index 1a5063d..c0a1f21 100644 --- a/.github/workflows/release-rc.yml +++ b/.github/workflows/release-rc.yml @@ -1,6 +1,7 @@ name: Release RC on: + workflow_dispatch: push: branches: - next @@ -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 @@ -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" @@ -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: | @@ -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 }}" + 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.