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]
> **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
> 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
@@ -44,18 +47,28 @@ To release a new version (e.g., `v2.5.1`), follow these steps:
git pull origin main
git checkout -b release/v2.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,
and wait for required `verify`, `node20`, and `e2e` checks.
3. After the PR is merged, fast-forward local `main` and create an **annotated**
tag on that exact commit:
3. After the PR is merged, fast-forward local `main`, wait for `verify`,
`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
git checkout 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"
```
4. Verify the tag target, then push it once:
5. Verify the tag target, then push it once:
```bash
test "$(git rev-parse v2.5.1^{commit})" = "$(git rev-parse origin/main)"
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
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:fix": "eslint . --fix",
"prepare:release": "node scripts/prepare-release.mjs",
"release:gate": "node scripts/release-local-gate.mjs",
"subset-flags": "node website/tools/subset-flag-font.mjs",
"test": "npm run verify",
"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:coverage
npm run prepare:release -- 3.1.5
npm run release:gate -- 3.1.5 --candidate
```
- `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: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 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
+1
View File
@@ -42,6 +42,7 @@ export const EXTERNALLY_GATED_SOURCES = Object.freeze({
'scripts/check-coverage-inventory.mjs',
'scripts/coverage-plan.mjs',
'scripts/prepare-release.mjs',
'scripts/release-local-gate.mjs',
'scripts/release-preflight.mjs',
'scripts/test-audio-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) {
return String(text).split(/\r?\n/u).filter(Boolean).map(line => {
const [name, conclusion, url = ''] = line.split('\t');
if (!name || !conclusion) throw new Error(`Invalid check-run record: ${line}`);
const fields = line.split('\t');
if (fields.length < 2 || !fields[0]) throw new Error(`Invalid check-run record: ${line}`);
const [name, conclusion = '', url = ''] = fields;
return { name, conclusion, url };
});
}
@@ -21,7 +22,7 @@ export function validateRequiredChecks(checkRuns, required = REQUIRED_RELEASE_CH
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.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}`);
}
}
+18 -2
View File
@@ -17,14 +17,30 @@ describe('release preflight helpers', () => {
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', () => {
expect(() => validateRequiredChecks([{ name: 'verify', conclusion: 'success' }]))
.toThrow('Required check is missing for the release commit: node20');
expect(() => validateRequiredChecks([
{ name: 'verify', conclusion: 'success' },
{ name: 'node20', conclusion: 'success' },
{ name: 'e2e', conclusion: 'in_progress' }
])).toThrow('Required check e2e did not succeed: in_progress');
{ name: 'e2e', conclusion: '' }
])).toThrow('Required check e2e did not succeed: pending');
expect(() => validateRequiredChecks([
{ name: 'verify', conclusion: 'failure' },
{ name: 'node20', conclusion: 'success' },