fix: enforce CI-parity release gates

This commit is contained in:
KoalaDev
2026-08-25 02:07:10 +02:00
parent eb60d74579
commit 26c5a1a08f
9 changed files with 262 additions and 10 deletions
+37
View File
@@ -0,0 +1,37 @@
# Repository agent release rules
These rules are mandatory for every automated agent working in this repository.
## Before changing code
- Run `git pull --ff-only` before starting.
- Inspect branch, remote tracking, dirty state, and relevant release workflow.
- Preserve unrelated work and stage only reviewed paths.
## Before claiming a release is ready
- Never treat a host macOS browser run as GitHub Linux parity.
- On a release branch, commit all intended changes and run:
`npm run release:gate -- MAJOR.MINOR.PATCH --candidate`
- This command must use the official lockfile-matched Playwright image with
`linux/amd64`, Ubuntu Noble, and `CI=1`; it must run clean installs, full
verification, all browser E2E tests, the relay image build, and health smoke.
- A failed, interrupted, ARM64, skipped, or partial run is not a passing gate.
- Do not say "release-ready" until the candidate gate and PR required checks
are green for the exact commit.
## Before pushing a release tag
- Merge through a PR; resolve every review thread.
- Fast-forward local `main` and confirm a clean tree at exact `origin/main`.
- Wait for `verify`, `node20`, and `e2e` on the merged `main` commit.
- Run the final gate: `npm run release:gate -- MAJOR.MINOR.PATCH`.
- Create an annotated tag only after the final gate succeeds.
- Confirm tag target equals `origin/main`, then push the tag once.
- Monitor the complete release workflow. Distinguish tag push, CI, draft assets,
container publication, attestations, and public GitHub Release status.
- Never call a release complete while any job is pending, failed, or skipped.
The final gate deliberately simulates the release workflow seeing its own
in-progress `preflight` check. This is a permanent regression guard for the
failed first `v3.1.5` release attempt.
+23 -5
View File
@@ -9,7 +9,10 @@ KoalaSync uses a gated release pipeline triggered by immutable Git tags.
> [!IMPORTANT] > [!IMPORTANT]
> **DO NOT** edit individual version files or tag an unmerged branch. Run > **DO NOT** edit individual version files or tag an unmerged branch. Run
> `npm run prepare:release -- MAJOR.MINOR.PATCH` on a branch, review all generated > `npm run prepare:release -- MAJOR.MINOR.PATCH` on a branch, review all generated
> source changes, and merge them through a pull request with successful CI. > source changes, then run the exact Linux/AMD64 candidate gate before opening
> or updating the pull request:
> `npm run release:gate -- MAJOR.MINOR.PATCH --candidate`.
> Merge only through a pull request with successful CI.
### How it Works ### How it Works
@@ -44,18 +47,28 @@ To release a new version (e.g., `v2.5.1`), follow these steps:
git pull origin main git pull origin main
git checkout -b release/v2.5.1 git checkout -b release/v2.5.1
npm run prepare:release -- 2.5.1 npm run prepare:release -- 2.5.1
npm run verify git add <reviewed-release-paths>
git commit -m "release: prepare v2.5.1"
npm run release:gate -- 2.5.1 --candidate
``` ```
2. Commit the release notes and prepared version changes, open a pull request, 2. Commit the release notes and prepared version changes, open a pull request,
and wait for required `verify`, `node20`, and `e2e` checks. and wait for required `verify`, `node20`, and `e2e` checks.
3. After the PR is merged, fast-forward local `main` and create an **annotated** 3. After the PR is merged, fast-forward local `main`, wait for `verify`,
tag on that exact commit: `node20`, and `e2e` on the merge commit, then run the final local gate. It
refuses a dirty tree, a non-`main` branch, a commit different from
`origin/main`, missing/failed required checks, version drift, non-AMD64
Linux browser execution, or an unhealthy relay container:
```bash ```bash
git checkout main git checkout main
git pull --ff-only origin main git pull --ff-only origin main
npm run release:gate -- 2.5.1
```
4. Only after that command succeeds, create an **annotated** tag on the exact
checked commit:
```bash
git tag -a v2.5.1 -m "Release v2.5.1" git tag -a v2.5.1 -m "Release v2.5.1"
``` ```
4. Verify the tag target, then push it once: 5. Verify the tag target, then push it once:
```bash ```bash
test "$(git rev-parse v2.5.1^{commit})" = "$(git rev-parse origin/main)" test "$(git rev-parse v2.5.1^{commit})" = "$(git rev-parse origin/main)"
git push origin v2.5.1 git push origin v2.5.1
@@ -63,3 +76,8 @@ To release a new version (e.g., `v2.5.1`), follow these steps:
Never reuse or move a published tag. Monitor every release job and verify both Never reuse or move a published tag. Monitor every release job and verify both
the public GitHub assets and GHCR digest before calling the release complete. the public GitHub assets and GHCR digest before calling the release complete.
`npm run verify` or a host-only Playwright run is not a substitute for
`release:gate`. The gate pins the official Playwright image to the exact
lockfile version and forces `linux/amd64`, matching GitHub's Ubuntu runner even
when the developer host is macOS or ARM64.
+1
View File
@@ -13,6 +13,7 @@
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"prepare:release": "node scripts/prepare-release.mjs", "prepare:release": "node scripts/prepare-release.mjs",
"release:gate": "node scripts/release-local-gate.mjs",
"subset-flags": "node website/tools/subset-flag-font.mjs", "subset-flags": "node website/tools/subset-flag-font.mjs",
"test": "npm run verify", "test": "npm run verify",
"test:e2e": "playwright test --config tests/e2e/playwright.config.mjs", "test:e2e": "playwright test --config tests/e2e/playwright.config.mjs",
+2
View File
@@ -11,6 +11,7 @@ npm run lint
npm run test:unit npm run test:unit
npm run test:coverage npm run test:coverage
npm run prepare:release -- 3.1.5 npm run prepare:release -- 3.1.5
npm run release:gate -- 3.1.5 --candidate
``` ```
- `npm run build:extension` runs `scripts/build-extension.cjs`. - `npm run build:extension` runs `scripts/build-extension.cjs`.
@@ -19,6 +20,7 @@ npm run prepare:release -- 3.1.5
- `npm run test:unit` runs Vitest tests. - `npm run test:unit` runs Vitest tests.
- `npm run test:coverage` runs the same tests with the enforced coverage floor. - `npm run test:coverage` runs the same tests with the enforced coverage floor.
- `npm run prepare:release -- MAJOR.MINOR.PATCH` updates every release-version source consistently before the release PR. - `npm run prepare:release -- MAJOR.MINOR.PATCH` updates every release-version source consistently before the release PR.
- `npm run release:gate -- MAJOR.MINOR.PATCH --candidate` runs the complete release candidate in the lockfile-matched official Playwright Linux/AMD64 image, then builds and health-smokes the relay container. After merge, omit `--candidate`; final mode additionally requires clean current `main`, exact `origin/main`, and successful `verify`, `node20`, and `e2e` checks while simulating the release workflow's own pending preflight check.
## build-extension.cjs ## build-extension.cjs
+1
View File
@@ -42,6 +42,7 @@ export const EXTERNALLY_GATED_SOURCES = Object.freeze({
'scripts/check-coverage-inventory.mjs', 'scripts/check-coverage-inventory.mjs',
'scripts/coverage-plan.mjs', 'scripts/coverage-plan.mjs',
'scripts/prepare-release.mjs', 'scripts/prepare-release.mjs',
'scripts/release-local-gate.mjs',
'scripts/release-preflight.mjs', 'scripts/release-preflight.mjs',
'scripts/test-audio-settings.mjs', 'scripts/test-audio-settings.mjs',
'scripts/test-chat-settings.mjs', 'scripts/test-chat-settings.mjs',
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { versionFromTag } from './release-artifact-checks.mjs';
import {
parseCheckRuns,
validateReleaseSourceVersion,
validateRequiredChecks
} from './release-preflight.mjs';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
function capture(command, args) {
return execFileSync(command, args, {
cwd: repoRoot,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']
}).trim();
}
function run(command, args) {
execFileSync(command, args, {
cwd: repoRoot,
stdio: 'inherit'
});
}
export function parseGateArgs(args) {
const values = Array.from(args);
const candidate = values.includes('--candidate');
const positional = values.filter(value => value !== '--candidate');
if (positional.length !== 1) {
throw new Error('Usage: npm run release:gate -- MAJOR.MINOR.PATCH [--candidate]');
}
const version = versionFromTag(`v${positional[0]}`);
return { version, candidate };
}
export function playwrightImageFromLock(lock) {
const version = lock?.packages?.['node_modules/@playwright/test']?.version;
if (!/^\d+\.\d+\.\d+$/u.test(version || '')) {
throw new Error('package-lock.json must pin node_modules/@playwright/test to an exact version');
}
return `mcr.microsoft.com/playwright:v${version}-noble`;
}
export function linuxGateCommand() {
return [
'git clone --no-local /src /work',
'cd /work',
'npm ci',
'npm ci --prefix server',
'npm run verify',
'npm run test:e2e'
].join(' && ');
}
function assertCleanTree() {
const status = capture('git', ['status', '--porcelain=v1']);
if (status) throw new Error(`release gate requires a clean working tree:\n${status}`);
}
function assertFinalMainChecks() {
const branch = capture('git', ['branch', '--show-current']);
if (branch !== 'main') throw new Error(`final release gate requires branch main, found ${branch || '<detached>'}`);
const head = capture('git', ['rev-parse', 'HEAD']);
const remoteMain = capture('git', ['rev-parse', 'origin/main']);
if (head !== remoteMain) throw new Error(`HEAD ${head} does not match origin/main ${remoteMain}`);
const checksText = capture('gh', [
'api', `repos/Shik3i/KoalaSync/commits/${head}/check-runs`,
'--jq', '.check_runs[] | [.name, .conclusion, .html_url] | @tsv'
]);
// Model the release workflow querying this commit while its own preflight
// check is still running. This exact state broke the first v3.1.5 attempt.
const checks = parseCheckRuns(`${checksText}\npreflight\t\tlocal://self-check`);
validateRequiredChecks(checks);
}
async function smokeRelayImage(image) {
const containerId = capture('docker', [
'run', '--detach', '--platform', 'linux/amd64', '--publish', '127.0.0.1::3000',
'--env', 'SERVER_SALT=release-local-gate-salt-with-more-than-thirty-two-chars',
image
]);
try {
const portOutput = capture('docker', ['port', containerId, '3000/tcp']);
const port = /:(\d+)$/u.exec(portOutput)?.[1];
if (!port) throw new Error(`could not resolve relay host port: ${portOutput}`);
const deadline = Date.now() + 30000;
while (Date.now() < deadline) {
try {
const response = await fetch(`http://127.0.0.1:${port}/health`, {
signal: globalThis.AbortSignal.timeout(1000)
});
if (response.ok) return;
} catch (_error) {
// Container is still starting.
}
await new Promise(resolve => setTimeout(resolve, 250));
}
run('docker', ['logs', containerId]);
throw new Error('relay container did not become healthy within 30 seconds');
} finally {
run('docker', ['rm', '--force', containerId]);
}
}
export async function runReleaseGate({ version, candidate }) {
assertCleanTree();
validateReleaseSourceVersion(version, repoRoot);
if (!candidate) assertFinalMainChecks();
const lock = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package-lock.json'), 'utf8'));
const playwrightImage = playwrightImageFromLock(lock);
run('docker', ['pull', '--platform', 'linux/amd64', playwrightImage]);
run('docker', [
'run', '--rm', '--platform', 'linux/amd64', '--ipc=host', '--env', 'CI=1',
'--volume', `${repoRoot}:/src:ro`, playwrightImage,
'bash', '-lc', linuxGateCommand()
]);
const relayImage = `koalasync:${version}-release-gate`;
run('docker', [
'build', '--platform', 'linux/amd64',
'--file', 'server/Dockerfile', '--tag', relayImage, '.'
]);
await smokeRelayImage(relayImage);
console.log(`Local ${candidate ? 'candidate' : 'final'} release gate passed for v${version}`);
}
const isMainModule = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (isMainModule) {
try {
await runReleaseGate(parseGateArgs(process.argv.slice(2)));
} catch (error) {
console.error(`Local release gate failed: ${error.message}`);
process.exitCode = 1;
}
}
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import {
linuxGateCommand,
parseGateArgs,
playwrightImageFromLock
} from './release-local-gate.mjs';
describe('local release gate contract', () => {
it('requires one exact version and makes candidate mode explicit', () => {
expect(parseGateArgs(['3.1.5'])).toEqual({ version: '3.1.5', candidate: false });
expect(parseGateArgs(['3.1.5', '--candidate'])).toEqual({ version: '3.1.5', candidate: true });
expect(() => parseGateArgs([])).toThrow('Usage: npm run release:gate');
expect(() => parseGateArgs(['3.1'])).toThrow('Release tag must match vMAJOR.MINOR.PATCH');
});
it('derives an exact official Playwright Linux image from the lockfile', () => {
expect(playwrightImageFromLock({
packages: { 'node_modules/@playwright/test': { version: '1.62.0' } }
})).toBe('mcr.microsoft.com/playwright:v1.62.0-noble');
expect(() => playwrightImageFromLock({ packages: {} })).toThrow('must pin');
});
it('runs the complete CI-equivalent dependency, verify, and browser sequence', () => {
expect(linuxGateCommand()).toBe([
'git clone --no-local /src /work',
'cd /work',
'npm ci',
'npm ci --prefix server',
'npm run verify',
'npm run test:e2e'
].join(' && '));
});
});
+4 -3
View File
@@ -10,8 +10,9 @@ export const REQUIRED_RELEASE_CHECKS = Object.freeze(['verify', 'node20', 'e2e']
export function parseCheckRuns(text) { export function parseCheckRuns(text) {
return String(text).split(/\r?\n/u).filter(Boolean).map(line => { return String(text).split(/\r?\n/u).filter(Boolean).map(line => {
const [name, conclusion, url = ''] = line.split('\t'); const fields = line.split('\t');
if (!name || !conclusion) throw new Error(`Invalid check-run record: ${line}`); if (fields.length < 2 || !fields[0]) throw new Error(`Invalid check-run record: ${line}`);
const [name, conclusion = '', url = ''] = fields;
return { name, conclusion, url }; return { name, conclusion, url };
}); });
} }
@@ -21,7 +22,7 @@ export function validateRequiredChecks(checkRuns, required = REQUIRED_RELEASE_CH
const matches = checkRuns.filter(check => check.name === name); const matches = checkRuns.filter(check => check.name === name);
if (matches.length === 0) throw new Error(`Required check is missing for the release commit: ${name}`); if (matches.length === 0) throw new Error(`Required check is missing for the release commit: ${name}`);
if (matches.some(check => check.conclusion !== 'success')) { if (matches.some(check => check.conclusion !== 'success')) {
const conclusions = matches.map(check => check.conclusion).join(', '); const conclusions = matches.map(check => check.conclusion || 'pending').join(', ');
throw new Error(`Required check ${name} did not succeed: ${conclusions}`); throw new Error(`Required check ${name} did not succeed: ${conclusions}`);
} }
} }
+18 -2
View File
@@ -17,14 +17,30 @@ describe('release preflight helpers', () => {
expect(() => validateRequiredChecks(checks)).not.toThrow(); expect(() => validateRequiredChecks(checks)).not.toThrow();
}); });
it('ignores an unrelated in-progress release check while validating required checks', () => {
const checks = parseCheckRuns([
'verify\tsuccess\thttps://example.test/verify',
'node20\tsuccess\thttps://example.test/node20',
'e2e\tsuccess\thttps://example.test/e2e',
'preflight\t\thttps://example.test/preflight'
].join('\n'));
expect(checks.at(-1)).toEqual({
name: 'preflight',
conclusion: '',
url: 'https://example.test/preflight'
});
expect(() => validateRequiredChecks(checks)).not.toThrow();
});
it('rejects missing, pending, and failed release checks', () => { it('rejects missing, pending, and failed release checks', () => {
expect(() => validateRequiredChecks([{ name: 'verify', conclusion: 'success' }])) expect(() => validateRequiredChecks([{ name: 'verify', conclusion: 'success' }]))
.toThrow('Required check is missing for the release commit: node20'); .toThrow('Required check is missing for the release commit: node20');
expect(() => validateRequiredChecks([ expect(() => validateRequiredChecks([
{ name: 'verify', conclusion: 'success' }, { name: 'verify', conclusion: 'success' },
{ name: 'node20', conclusion: 'success' }, { name: 'node20', conclusion: 'success' },
{ name: 'e2e', conclusion: 'in_progress' } { name: 'e2e', conclusion: '' }
])).toThrow('Required check e2e did not succeed: in_progress'); ])).toThrow('Required check e2e did not succeed: pending');
expect(() => validateRequiredChecks([ expect(() => validateRequiredChecks([
{ name: 'verify', conclusion: 'failure' }, { name: 'verify', conclusion: 'failure' },
{ name: 'node20', conclusion: 'success' }, { name: 'node20', conclusion: 'success' },