From 26c5a1a08f63de6c67d2ce82ba1736bb0f2fae9e Mon Sep 17 00:00:00 2001 From: KoalaDev <6156589+Shik3i@users.noreply.github.com> Date: Tue, 25 Aug 2026 02:07:10 +0200 Subject: [PATCH] fix: enforce CI-parity release gates --- AGENTS.md | 37 +++++++ docs/devops.md | 28 +++++- package.json | 1 + scripts/README.md | 2 + scripts/coverage-plan.mjs | 1 + scripts/release-local-gate.mjs | 143 ++++++++++++++++++++++++++++ scripts/release-local-gate.test.mjs | 33 +++++++ scripts/release-preflight.mjs | 7 +- scripts/release-preflight.test.mjs | 20 +++- 9 files changed, 262 insertions(+), 10 deletions(-) create mode 100644 AGENTS.md create mode 100644 scripts/release-local-gate.mjs create mode 100644 scripts/release-local-gate.test.mjs diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d73d95d --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/docs/devops.md b/docs/devops.md index 6cf702b..d4a800b 100644 --- a/docs/devops.md +++ b/docs/devops.md @@ -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 + 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. diff --git a/package.json b/package.json index 5370a5e..46bd8be 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/README.md b/scripts/README.md index c32a8a7..fb8d82e 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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 diff --git a/scripts/coverage-plan.mjs b/scripts/coverage-plan.mjs index 601dabd..9b47dfb 100644 --- a/scripts/coverage-plan.mjs +++ b/scripts/coverage-plan.mjs @@ -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', diff --git a/scripts/release-local-gate.mjs b/scripts/release-local-gate.mjs new file mode 100644 index 0000000..d4a73e5 --- /dev/null +++ b/scripts/release-local-gate.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 || ''}`); + 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; + } +} diff --git a/scripts/release-local-gate.test.mjs b/scripts/release-local-gate.test.mjs new file mode 100644 index 0000000..50461cc --- /dev/null +++ b/scripts/release-local-gate.test.mjs @@ -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(' && ')); + }); +}); diff --git a/scripts/release-preflight.mjs b/scripts/release-preflight.mjs index da6dcaa..fbc00c2 100644 --- a/scripts/release-preflight.mjs +++ b/scripts/release-preflight.mjs @@ -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}`); } } diff --git a/scripts/release-preflight.test.mjs b/scripts/release-preflight.test.mjs index 52390f7..788dbb7 100644 --- a/scripts/release-preflight.test.mjs +++ b/scripts/release-preflight.test.mjs @@ -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' },