mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-28 19:47:25 +00:00
fix: enforce CI-parity release gates
This commit is contained in:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(' && '));
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user