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
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env node
/**
* Release-order guard for the closed detector.
*
* The open runtime links a prebuilt detector archive at build time
* (crates/core/build.rs downloads it for the pinned DETECTOR_VERSION). An
* engine release therefore cannot be built until the detector release exists.
* This script verifies that `detector-v<DETECTOR_VERSION>` is fully published
* on the public repo's GitHub Releases: one archive + .sha256 per target and
* the browser bundle the extension vendors.
*
* node scripts/check-detector-release.mjs # exits 1 and lists what is missing
* node scripts/check-detector-release.mjs --json # machine-readable
*
* Environment:
* IMPECCABLE_DETECTOR_BASE release root (default: the public repo's GitHub Releases;
* the same variable crates/core/build.rs honors)
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
export const DEFAULT_DETECTOR_BASE = 'https://github.com/pbakaus/impeccable/releases/download';
export const DETECTOR_TARGETS = ['darwin-arm64', 'darwin-x64', 'linux-x64', 'linux-arm64', 'windows-x64'];
export const BROWSER_BUNDLE_ASSET = 'detector-browser-bundle.zip';
export function readDetectorVersion(root = ROOT) {
return fs.readFileSync(path.join(root, 'DETECTOR_VERSION'), 'utf-8').trim();
}
/** The archive asset name for one target, as build.rs and the detector CI spell it. */
export function archiveAsset(target) {
return target.startsWith('windows-') ? `impeccable_detector-${target}.lib` : `libimpeccable_detector-${target}.a`;
}
export function assetUrl(version, asset, base = process.env.IMPECCABLE_DETECTOR_BASE || DEFAULT_DETECTOR_BASE) {
return `${base.replace(/\/$/, '')}/detector-v${version}/${asset}`;
}
// A ranged GET is the most portable existence probe: GitHub release downloads
// redirect to a signed storage URL that answers HEAD inconsistently.
async function urlExists(url, fetchImpl = fetch) {
try {
const res = await fetchImpl(url, { method: 'GET', headers: { Range: 'bytes=0-0' }, redirect: 'follow' });
if (res.body && typeof res.body.cancel === 'function') await res.body.cancel().catch(() => {});
return res.status === 200 || res.status === 206;
} catch {
return false;
}
}
/**
* @returns {Promise<{ ok: boolean, version: string, base: string, missing: Array<{ kind: string, target?: string, what: string, url: string }> }>}
*/
export async function checkDetectorRelease({
version = readDetectorVersion(),
base = process.env.IMPECCABLE_DETECTOR_BASE || DEFAULT_DETECTOR_BASE,
fetchImpl = fetch,
} = {}) {
const missing = [];
const probes = [];
for (const target of DETECTOR_TARGETS) {
const asset = archiveAsset(target);
const url = assetUrl(version, asset, base);
probes.push(
urlExists(url, fetchImpl).then((ok) => { if (!ok) missing.push({ kind: 'archive', target, what: asset, url }); }),
urlExists(`${url}.sha256`, fetchImpl).then((ok) => { if (!ok) missing.push({ kind: 'checksum', target, what: `${asset}.sha256`, url: `${url}.sha256` }); }),
);
}
const bundleUrl = assetUrl(version, BROWSER_BUNDLE_ASSET, base);
probes.push(
urlExists(bundleUrl, fetchImpl).then((ok) => { if (!ok) missing.push({ kind: 'bundle', what: BROWSER_BUNDLE_ASSET, url: bundleUrl }); }),
);
await Promise.all(probes);
// Plain byte order (not localeCompare, which files punctuation before
// letters): per-target rows first, the bundle row last.
const order = { archive: 0, checksum: 1, bundle: 2 };
const key = (m) => m.target || 'zz-bundle';
missing.sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0) || order[a.kind] - order[b.kind]);
return { ok: missing.length === 0, version, base, missing };
}
function main() {
const json = process.argv.includes('--json');
return checkDetectorRelease().then((result) => {
if (json) {
console.log(JSON.stringify(result, null, 2));
process.exit(result.ok ? 0 : 1);
}
if (result.ok) {
console.log(`✓ detector v${result.version} release is complete: ${DETECTOR_TARGETS.length} archives + .sha256 + ${BROWSER_BUNDLE_ASSET} are published.`);
console.log(` release base: ${result.base}`);
process.exit(0);
}
console.error(`✗ detector v${result.version} release is INCOMPLETE. Missing ${result.missing.length} asset(s):`);
for (const m of result.missing) console.error(` · ${m.what}\n ${m.url}`);
console.error('');
console.error(`Publish detector v${result.version} (tag v${result.version} in the private detector repo; its CI`);
console.error(`uploads the archives to this repo's detector-v${result.version} release) BEFORE tagging an engine`);
console.error('release: crates/core/build.rs downloads the archive for every target it builds.');
console.error(` release base: ${result.base}`);
process.exit(1);
});
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main();
}
+5 -5
View File
@@ -4,7 +4,7 @@
*
* 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
* ENGINE_VERSION exists FIRST: the five platform binaries in the engine-v<version> GitHub Release
* release channel AND the five @impeccable/cli-<os>-<arch> 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
@@ -23,7 +23,7 @@
* node scripts/check-engine-release.mjs --json # machine-readable report
*
* Environment:
* IMPECCABLE_DOWNLOAD_BASE dist release channel root (default: the public dist releases)
* IMPECCABLE_DOWNLOAD_BASE release root (default: the public repo's GitHub Releases)
*/
import {
ENGINE_TARGETS,
@@ -102,7 +102,7 @@ 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}`);
console.log(` release base: ${base}`);
return;
}
console.error(`✗ engine v${version} release is INCOMPLETE — ${missing.length} asset(s) missing:`);
@@ -111,11 +111,11 @@ function report(result) {
console.error(` ${m.url}`);
}
console.error('');
console.error(`Publish engine v${version} to the impeccable-dist release channel AND the`);
console.error(`Publish engine v${version} (tag engine-v${version}, bun run release:engine) AND the`);
console.error('five @impeccable/cli-<os>-<arch> 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}`);
console.error(` release base: ${base}`);
}
async function main(argv = process.argv.slice(2)) {
+15 -1
View File
@@ -14,11 +14,24 @@ const isSchedule = eventName === 'schedule';
const changedFiles = localNoChanges || isSchedule ? [] : getChangedFiles();
const forceDeterministic = localNoChanges || isSchedule || eventName === 'push' || eventName === 'workflow_dispatch';
const forceOptIn = eventName === 'workflow_dispatch';
// The Rust workspace (the engine) builds and tests when its own inputs move.
// tests/oracle is included: the goldens are the engine's behavior gate and
// the oracle job replays them against a source build.
const RUST_PATTERNS = [
/^crates\//,
/^Cargo\.(toml|lock)$/,
/^rust-toolchain\.toml$/,
/^DETECTOR_VERSION$/,
/^tests\/oracle\//,
/^\.github\/workflows\/ci\.yml$/,
];
const rustChanged = changedFiles.some((file) => RUST_PATTERNS.some((re) => re.test(file)));
const plan = isSchedule
? {
core: true,
oracle: true,
rust: true,
detector: true,
live: true,
framework: true,
@@ -31,6 +44,7 @@ const plan = isSchedule
: {
core: true,
oracle: forceDeterministic || matchesSuiteTriggers('oracle', changedFiles),
rust: forceDeterministic || rustChanged,
detector: forceDeterministic || matchesSuiteTriggers('detector', changedFiles),
live: forceDeterministic || matchesSuiteTriggers('live', changedFiles),
framework: forceDeterministic || matchesSuiteTriggers('framework', changedFiles),
@@ -98,7 +112,7 @@ function printSummary(outputs, files) {
const deterministic = DEFAULT_SUITES.map((name) => `${name}=${outputs[name]}`).join(' ');
console.log(`Event: ${eventName || 'local'}`);
console.log(`Changed files: ${files.length}`);
console.log(`Deterministic suites: ${deterministic}`);
console.log(`Deterministic suites: ${deterministic} rust=${outputs.rust}`);
console.log(
[
`cli_remote_e2e=${outputs.cli_remote_e2e}`,
+4 -4
View File
@@ -11,10 +11,10 @@
* node scripts/fetch-engine.mjs --lenient # a target that cannot be fetched warns instead of failing
*
* Environment (same names the launcher honors):
* IMPECCABLE_DOWNLOAD_BASE release channel root (default: the public dist releases)
* IMPECCABLE_DOWNLOAD_BASE release channel root (default: the public repo's GitHub Releases)
* IMPECCABLE_BIN copy this local binary for the current platform instead of downloading
*
* The URL scheme is the launcher's: <base>/v<version>/impeccable-<os>-<arch>[.exe],
* The URL scheme is the launcher's: <base>/engine-v<version>/impeccable-<os>-<arch>[.exe],
* with an optional <asset>.sha256 next to it that is verified when present.
*/
import fs from 'node:fs';
@@ -24,7 +24,7 @@ import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
export const DEFAULT_DOWNLOAD_BASE = 'https://github.com/renaissance-geek-inc/impeccable-dist/releases/download';
export const DEFAULT_DOWNLOAD_BASE = 'https://github.com/pbakaus/impeccable/releases/download';
export const ENGINE_TARGETS = ['darwin-arm64', 'darwin-x64', 'linux-x64', 'linux-arm64', 'windows-x64'];
export function readEngineVersion(root = ROOT) {
@@ -43,7 +43,7 @@ export function binaryName(target) {
export function assetUrl(version, target, base = process.env.IMPECCABLE_DOWNLOAD_BASE || DEFAULT_DOWNLOAD_BASE) {
const asset = `impeccable-${target}${target.startsWith('windows-') ? '.exe' : ''}`;
return `${base.replace(/\/$/, '')}/v${version}/${asset}`;
return `${base.replace(/\/$/, '')}/engine-v${version}/${asset}`;
}
export function binaryPath(target, dest = path.join(ROOT, 'skill', 'scripts', 'bin')) {
+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.');
}
+1
View File
@@ -61,6 +61,7 @@ export const SUITES = {
'tests/hook-build.test.mjs',
'tests/openai-plugin.test.mjs',
'tests/release.test.mjs',
'tests/check-detector-release.test.mjs',
'tests/skill-reference.test.mjs',
'tests/readme-gitignore.test.mjs',
'tests/test-suites.test.mjs',