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
+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' },