reorg: public plumbing for the in-repo Rust workspace and the two-release flow

The engine binaries move from the impeccable-dist channel to this repo's own
GitHub Releases (tag engine-v<ENGINE_VERSION>), and the closed detector the
engine links arrives as detector-v<DETECTOR_VERSION> releases on the same
repo. This commit wires the public side for that; the crates themselves land
in the next commit.

- Launcher (sh + cmd), npm shim, fetch-engine and check-engine-release now
  download from github.com/pbakaus/impeccable/releases/download/engine-v<X>/.
- release.mjs gains `engine`: verifies ENGINE_VERSION against the platform
  package pins and the detector release, tags, pushes; release-engine.yml
  builds the five targets and publishes. check-detector-release.mjs is the
  matching release-order guard (with tests).
- Root Cargo.toml (workspace, lto = false with the reason), rust-toolchain.toml
  (exact pin), DETECTOR_VERSION, /target ignored.
- CI: rust + rust-windows jobs and an oracle job that replays the goldens
  against a source build, warn-only until the first detector release exists;
  ci-test-plan exposes a `rust` output.
- docs/ENGINE.md (the crate map and the closed-detector mechanism) and the
  CLAUDE.md engine, release-order and rules sections.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY
This commit is contained in:
Paul Bakaus
2026-09-01 14:05:39 -07:00
co-authored by Claude Fable 5.1
parent 6c474b1c79
commit e355ebf714
20 changed files with 714 additions and 58 deletions
+104 -6
View File
@@ -1,8 +1,13 @@
#!/usr/bin/env node
// Tags and publishes a GitHub release for one of three independently versioned
// components: skill, cli, extension.
// Tags and publishes a GitHub release for one of the independently versioned
// components: skill, cli, extension, engine.
//
// Usage: node scripts/release.mjs <skill|cli|extension> [--dry-run]
// Usage: node scripts/release.mjs <skill|cli|extension|engine> [--dry-run]
//
// `engine` is different: it only tags `engine-v<ENGINE_VERSION>` and pushes the
// tag; .github/workflows/release-engine.yml builds the five binaries and
// publishes the GitHub Release. It has no changelog entry and no local
// artifacts, and it is gated on the closed detector release the build links.
//
// Refuses on a dirty tree, an unpushed HEAD, or a missing changelog entry.
// For the skill component, also reruns `bun run build:release` and refuses if the
@@ -13,6 +18,7 @@ import { execSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { checkEngineRelease } from './check-engine-release.mjs';
import { checkDetectorRelease, readDetectorVersion } from './check-detector-release.mjs';
import { readEngineVersion } from './fetch-engine.mjs';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -64,6 +70,13 @@ const COMPONENTS = {
tweetHeader: (v) => `Impeccable browser extension v${v} is out.`,
tweetCta: null,
},
engine: {
// Version comes from the root ENGINE_VERSION file, not a JSON manifest;
// releaseEngine() below owns this component's whole flow.
manifest: 'ENGINE_VERSION',
tagPrefix: 'engine-v',
label: 'Engine',
},
};
const REPO_URL = 'https://github.com/pbakaus/impeccable';
@@ -74,11 +87,16 @@ const dryRun = args.includes('--dry-run');
const component = args.find((a) => !a.startsWith('--'));
if (!component || !COMPONENTS[component]) {
console.error('usage: release.mjs <skill|cli|extension> [--dry-run]');
console.error('usage: release.mjs <skill|cli|extension|engine> [--dry-run]');
process.exit(1);
}
const cfg = COMPONENTS[component];
if (component === 'engine') {
await releaseEngine();
process.exit(0);
}
function fail(msg) {
console.error(`${msg}`);
process.exit(1);
@@ -117,7 +135,7 @@ if (cfg.sibling) {
// 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-<os>-<arch>
// live: the five engine-v<version> release binaries + .sha256 and the five @impeccable/cli-<os>-<arch>
// 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.
@@ -130,7 +148,7 @@ if (cfg.engineGated && process.env.IMPECCABLE_SKIP_ENGINE_CHECK !== '1') {
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-<os>-<arch>\n` +
` Publish engine v${engineVersion} (bun run release:engine) AND the five @impeccable/cli-<os>-<arch>\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.'
);
@@ -365,3 +383,83 @@ function htmlToMarkdown(html) {
md = md.replace(/\n{3,}/g, '\n\n');
return md.trim();
}
// The engine release: verify, tag, push. CI does the building and publishing
// (release-engine.yml), so the maintainer's machine never needs five
// toolchains. Refuses when the detector release the build links against is
// not published: crates/core/build.rs downloads
// detector-v<DETECTOR_VERSION> for every target, so a missing archive would
// fail every matrix job after the tag is already pushed.
async function releaseEngine() {
step('Reading version from ENGINE_VERSION');
const version = readEngineVersion(repoRoot);
if (!/^\d+\.\d+\.\d+/.test(version)) fail(`ENGINE_VERSION "${version}" is not a version`);
ok(`Engine ${version}`);
step('Checking package.json optionalDependencies pin the same engine version');
const pkg = JSON.parse(readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
const pins = Object.entries(pkg.optionalDependencies || {}).filter(([name]) => name.startsWith('@impeccable/cli-'));
const wrong = pins.filter(([, range]) => String(range).replace(/^[^\d]*/, '') !== version);
if (wrong.length) fail(`package.json pins ${wrong.map(([n, r]) => `${n}@${r}`).join(', ')}; expected ${version}. Bump them with ENGINE_VERSION.`);
ok(`${pins.length} platform package pins agree`);
if (process.env.IMPECCABLE_SKIP_DETECTOR_CHECK !== '1') {
const detectorVersion = readDetectorVersion(repoRoot);
step(`Verifying detector v${detectorVersion} release assets are published (the engine build links them)`);
const result = await checkDetectorRelease({ version: detectorVersion });
if (!result.ok) {
console.error('✗ Detector release is incomplete. Missing assets:');
for (const m of result.missing) console.error(` · ${m.what}\n ${m.url}`);
fail(
`Refusing to tag engine ${version}: detector v${detectorVersion} is not fully published.\n` +
' Tag the detector repo first; its CI publishes the archives to this repo\'s detector-v release.\n' +
' Ordering: detector release → engine release → platform packages → skill/CLI release.'
);
}
ok(`detector v${detectorVersion} release assets all present`);
} else {
step('Skipping detector release-order guard (IMPECCABLE_SKIP_DETECTOR_CHECK=1)');
}
const tag = `${cfg.tagPrefix}${version}`;
step('Checking working tree is clean');
const status = run('git status --porcelain');
if (status) fail(`Working tree is dirty. Commit or stash first:\n${status}`);
ok('clean');
step('Checking HEAD is pushed to origin');
const branch = run('git rev-parse --abbrev-ref HEAD');
const head = run('git rev-parse HEAD');
let remoteHead;
try {
remoteHead = run(`git rev-parse origin/${branch}`);
} catch {
fail(`No tracking branch origin/${branch}. Push first.`);
}
if (head !== remoteHead) fail(`HEAD is ahead of origin/${branch}. Push your commits first.`);
ok(`origin/${branch} matches HEAD`);
step(`Verifying tag ${tag} does not already exist`);
let localTagExists = false;
try {
run(`git rev-parse -q --verify "refs/tags/${tag}"`);
localTagExists = true;
} catch {}
if (localTagExists) fail(`Tag ${tag} already exists locally.`);
const remoteTags = run('git ls-remote --tags origin');
if (remoteTags.split('\n').some((line) => line.endsWith(`refs/tags/${tag}`))) {
fail(`Tag ${tag} already exists on origin.`);
}
ok('tag is free');
step(`Creating annotated tag ${tag}`);
runMutating(`git tag -a ${tag} -m "Engine ${version}"`);
runMutating(`git push origin ${tag}`);
console.log(`\n✓ Engine ${version} tagged as ${tag}`);
console.log(`\n→ Next step: watch the release-engine workflow (${REPO_URL}/actions/workflows/release-engine.yml).`);
console.log(` It publishes the five binaries + .sha256 as ${REPO_URL}/releases/tag/${tag}.`);
console.log(' Then publish the five @impeccable/cli-<os>-<arch> npm platform packages, then release the CLI/skill.');
}