diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c516ad44f..5876d5f71 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -169,6 +169,39 @@ jobs: run: | echo "::warning title=Oracle not run::bun run fetch:engine could not download engine v$(cat ENGINE_VERSION) from the impeccable-dist release channel. The 762-case oracle behavior gate did NOT run. Expected until the first engine release is published; after that, publish the release assets and flip this job's continue-on-error to false." + # Release-order guard (triage decision D4). Verifies that the engine release for + # the pinned ENGINE_VERSION is fully published — the five dist binaries + .sha256 + # AND the five @impeccable/cli-- npm platform packages — before a skill + # release/merge that depends on them. The launcher, npm shim, and + # `impeccable install` all dead-end without those assets. + # + # continue-on-error is a release-time toggle: until the first engine release is + # published to impeccable-dist, the assets cannot exist and this job would block + # every PR. It emits a loud ::warning instead. Once v is live, + # flip `continue-on-error` to false so a MIS-ORDERED release (skill/CLI ahead of + # the engine) fails CI. release.mjs already hard-fails `release:skill`/`release:cli`. + engine-release-ready: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Setup Node + uses: actions/setup-node@v7 + with: + node-version: 24 + + - name: Check engine release assets for pinned ENGINE_VERSION + id: check + continue-on-error: true + run: node scripts/check-engine-release.mjs + + - name: Annotate missing engine release + if: steps.check.outcome != 'success' + run: | + echo "::warning title=Engine release not ready::The engine release for v$(cat ENGINE_VERSION) is not fully published to impeccable-dist and/or the @impeccable/cli-- npm platform packages. Releasing the skill/CLI (or merging) now would dead-end the launcher, the npm shim, and impeccable install. Expected until the first engine release exists; after that, publish the engine + platform packages and flip this job's continue-on-error to false so a mis-ordered release fails CI." + test: runs-on: ubuntu-latest needs: test-matrix diff --git a/CLAUDE.md b/CLAUDE.md index 4dbb4dd2a..8d7e268df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -288,6 +288,16 @@ Skill releases attach `dist/universal.zip`. Extension releases run `bun run buil If you need to fix release notes after the fact (typo, missing thank-you, formatting bug): `gh release edit --notes-file `. The release script's `htmlToMarkdown` function is the cleanest source for regenerating notes from the changelog. +### Release order is mechanically enforced (triage decision D4) + +The skill launcher, the npm shim (`cli/bin/cli.js`), and `impeccable install` all resolve the engine binary for the pinned `ENGINE_VERSION`. Nothing they do works until the engine release exists first. **The order is: publish the engine release, then the platform packages, then release/merge the skill (or CLI):** + +1. Publish engine `v` to the `impeccable-dist` release channel: the five `impeccable--[.exe]` binaries plus a `.sha256` beside each. +2. Publish the five `@impeccable/cli--@` npm platform packages. +3. Only then tag/publish the skill or CLI release, and only then merge a branch that bumps `ENGINE_VERSION` (the `sync-generated-output.yml` workflow rewrites provider dirs on merge to `main`). + +`scripts/check-engine-release.mjs` verifies all of that for the pinned version (HEAD/ranged-GET each dist asset, registry-probe each npm package; honors `IMPECCABLE_DOWNLOAD_BASE`). It exits non-zero and names exactly which assets are missing. `scripts/release.mjs` runs it as a hard gate before tagging the **skill** and **CLI** components and refuses to proceed when any asset is absent; the **extension** release is exempt because it ships a vendored WASM detector and never execs the engine. `IMPECCABLE_SKIP_ENGINE_CHECK=1` bypasses the gate only for the case where the assets exist but the registry probe is unreachable. CI's `engine-release-ready` job runs the same script; it is `continue-on-error: true` with a loud `::warning` until the first engine release is published, at which point flip it to `false` so a mis-ordered merge fails CI. + ## Adding New Commands All commands live under `/impeccable`. To add a new one: diff --git a/scripts/check-engine-release.mjs b/scripts/check-engine-release.mjs new file mode 100644 index 000000000..c8f92047d --- /dev/null +++ b/scripts/check-engine-release.mjs @@ -0,0 +1,135 @@ +#!/usr/bin/env node +/** + * Release-order guard (triage decision D4). + * + * The launcher (skill/scripts/impeccable), the npm shim (cli/bin/cli.js), and + * `impeccable install` all dead-end unless the engine release for the pinned + * ENGINE_VERSION exists FIRST: the five platform binaries in the impeccable-dist + * release channel AND the five @impeccable/cli-- npm platform packages. + * Nothing else mechanically stops a maintainer from tagging the skill release (or + * merging and letting the sync workflow rewrite provider dirs) before those assets + * are published, which breaks every install path. + * + * This script verifies, for the pinned engine version, that: + * 1. each of the five release binaries impeccable--[.exe] is fetchable + * 2. each binary's .sha256 sidecar is fetchable + * 3. each npm platform package @impeccable/cli--@ is published + * + * Exits 0 when everything is present, non-zero (naming exactly what is missing) + * otherwise. release.mjs runs it before an engine-dependent release; CI runs it + * as a soft warning until the first engine release exists. + * + * node scripts/check-engine-release.mjs # check the pinned ENGINE_VERSION + * node scripts/check-engine-release.mjs --json # machine-readable report + * + * Environment: + * IMPECCABLE_DOWNLOAD_BASE dist release channel root (default: the public dist releases) + */ +import { + ENGINE_TARGETS, + DEFAULT_DOWNLOAD_BASE, + readEngineVersion, + assetUrl, +} from './fetch-engine.mjs'; + +const NPM_REGISTRY = 'https://registry.npmjs.org'; + +// A ranged GET is the most portable existence probe: GitHub release downloads +// answer HEAD inconsistently across their 302 to object storage, but a +// `Range: bytes=0-0` GET follows the redirect and returns 200/206 for a real +// asset and 404 for a missing one without pulling the whole binary. +async function urlExists(url) { + try { + const res = await fetch(url, { redirect: 'follow', headers: { Range: 'bytes=0-0' } }); + return res.ok || res.status === 206; + } catch (err) { + return false; + } +} + +function npmPackageUrl(target, version) { + // Scoped name: the slash is percent-encoded for the registry path. + const name = `@impeccable/cli-${target}`; + return `${NPM_REGISTRY}/${name.replace('/', '%2f')}/${version}`; +} + +async function npmVersionExists(target, version) { + const url = npmPackageUrl(target, version); + try { + const res = await fetch(url, { redirect: 'follow' }); + return res.ok; + } catch { + return false; + } +} + +/** + * Check every asset for one engine version. Returns { ok, version, base, missing } + * where missing is a list of { kind, target, what, url } entries. + */ +export async function checkEngineRelease({ + version = readEngineVersion(), + base = process.env.IMPECCABLE_DOWNLOAD_BASE || DEFAULT_DOWNLOAD_BASE, +} = {}) { + const missing = []; + + await Promise.all( + ENGINE_TARGETS.map(async (target) => { + const binUrl = assetUrl(version, target, base); + const shaUrl = `${binUrl}.sha256`; + const npmUrl = npmPackageUrl(target, version); + + const [binOk, shaOk, npmOk] = await Promise.all([ + urlExists(binUrl), + urlExists(shaUrl), + npmVersionExists(target, version), + ]); + + if (!binOk) missing.push({ kind: 'binary', target, what: `impeccable-${target} binary`, url: binUrl }); + if (!shaOk) missing.push({ kind: 'checksum', target, what: `impeccable-${target} .sha256`, url: shaUrl }); + if (!npmOk) missing.push({ kind: 'npm', target, what: `@impeccable/cli-${target}@${version}`, url: npmUrl }); + }) + ); + + // Stable ordering for a readable report: by target, then binary/checksum/npm. + const order = { binary: 0, checksum: 1, npm: 2 }; + missing.sort((a, b) => ENGINE_TARGETS.indexOf(a.target) - ENGINE_TARGETS.indexOf(b.target) || order[a.kind] - order[b.kind]); + + return { ok: missing.length === 0, version, base, missing }; +} + +function report(result) { + const { ok, version, base, missing } = result; + if (ok) { + console.log(`✓ engine v${version} release is complete: all ${ENGINE_TARGETS.length} binaries + .sha256 + npm platform packages are published.`); + console.log(` dist channel: ${base}`); + return; + } + console.error(`✗ engine v${version} release is INCOMPLETE — ${missing.length} asset(s) missing:`); + for (const m of missing) { + console.error(` · ${m.what}`); + console.error(` ${m.url}`); + } + console.error(''); + console.error(`Publish engine v${version} to the impeccable-dist release channel AND the`); + console.error('five @impeccable/cli-- npm platform packages BEFORE releasing the'); + console.error('skill or merging rust-swap. Ordering: engine release → platform packages →'); + console.error('skill release/merge. See CLAUDE.md "Releases" and docs REVIEW-TRIAGE.md D4.'); + console.error(` dist channel: ${base}`); +} + +async function main(argv = process.argv.slice(2)) { + const json = argv.includes('--json'); + const result = await checkEngineRelease(); + if (json) { + console.log(JSON.stringify(result, null, 2)); + } else { + report(result); + } + return result.ok ? 0 : 1; +} + +// Run only when invoked directly, not when imported by release.mjs. +if (import.meta.url === `file://${process.argv[1]}`) { + main().then((code) => process.exit(code)); +} diff --git a/scripts/release.mjs b/scripts/release.mjs index b5d1bc9f6..e400dd975 100755 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -12,6 +12,8 @@ import { readFileSync, writeFileSync, unlinkSync, existsSync } from 'node:fs'; import { execSync } from 'node:child_process'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { checkEngineRelease } from './check-engine-release.mjs'; +import { readEngineVersion } from './fetch-engine.mjs'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -23,6 +25,9 @@ const COMPONENTS = { tagPrefix: 'skill-v', label: 'Skill', changelogLabel: 'v', + // The skill's launcher and `impeccable install` dead-end without the engine + // release for the pinned ENGINE_VERSION. Enforce release order (D4). + engineGated: true, buildCmd: 'bun run build:release', artifacts: ['dist/universal.zip'], postReleaseHint: null, @@ -34,6 +39,10 @@ const COMPONENTS = { tagPrefix: 'cli-v', label: 'CLI', changelogLabel: 'CLI v', + // The npm shim resolves the engine binary through the @impeccable/cli-- + // platform packages (pinned at ENGINE_VERSION) and the dist channel; publishing + // it before those exist strands `npx impeccable`. Enforce release order (D4). + engineGated: true, buildCmd: null, artifacts: [], postReleaseHint: 'Run `npm publish` next to push the package to the npm registry.', @@ -45,6 +54,9 @@ const COMPONENTS = { tagPrefix: 'ext-v', label: 'Extension', changelogLabel: 'Extension v', + // The extension ships a vendored WASM detector and does not exec the engine + // binary, so it is exempt from the engine release-order guard. + engineGated: false, buildCmd: 'bun run build:extension', artifacts: ['dist/extension.zip', 'dist/extension-firefox.zip'], postReleaseHint: @@ -103,6 +115,31 @@ if (cfg.sibling) { ok(`${cfg.sibling} agrees`); } +// Release-order guard (triage decision D4). Engine-gated components refuse to +// tag/publish until the engine release for the pinned ENGINE_VERSION is fully +// live: the five dist binaries + .sha256 and the five @impeccable/cli-- +// npm platform packages. Without this the launcher, the npm shim, and +// `impeccable install` all dead-end. Set IMPECCABLE_SKIP_ENGINE_CHECK=1 only +// when you know the assets exist and the registry probe is unreachable. +if (cfg.engineGated && process.env.IMPECCABLE_SKIP_ENGINE_CHECK !== '1') { + const engineVersion = readEngineVersion(repoRoot); + step(`Verifying engine v${engineVersion} release assets are published (D4 release-order guard)`); + const result = await checkEngineRelease({ version: engineVersion }); + if (!result.ok) { + console.error('✗ Engine release is incomplete. Missing assets:'); + for (const m of result.missing) console.error(` · ${m.what}\n ${m.url}`); + fail( + `Refusing to release ${cfg.label} ${version}: engine v${engineVersion} is not fully published.\n` + + ` Publish engine v${engineVersion} to impeccable-dist AND the five @impeccable/cli--\n` + + ' npm platform packages first. Ordering: engine release → platform packages → skill/CLI release.\n' + + ' See CLAUDE.md "Releases" and the engine repo docs/REVIEW-TRIAGE.md D4.' + ); + } + ok(`engine v${engineVersion} release assets all present`); +} else if (cfg.engineGated) { + step('Skipping engine release-order guard (IMPECCABLE_SKIP_ENGINE_CHECK=1)'); +} + const tag = `${cfg.tagPrefix}${version}`; step('Checking working tree is clean');