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
+53
View File
@@ -0,0 +1,53 @@
/**
* scripts/check-detector-release.mjs: the release-order guard for the closed
* detector archives that crates/core/build.rs downloads. Probes are injected
* so the test never touches the network.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
archiveAsset, assetUrl, checkDetectorRelease, DETECTOR_TARGETS, BROWSER_BUNDLE_ASSET, DEFAULT_DETECTOR_BASE,
} from '../scripts/check-detector-release.mjs';
const okResponse = { status: 206, body: { cancel: async () => {} } };
const missingResponse = { status: 404, body: null };
describe('check-detector-release', () => {
it('names one archive per target, .lib on Windows, and the detector-v tag in the URL', () => {
assert.equal(archiveAsset('darwin-arm64'), 'libimpeccable_detector-darwin-arm64.a');
assert.equal(archiveAsset('windows-x64'), 'impeccable_detector-windows-x64.lib');
assert.equal(
assetUrl('0.1.0', archiveAsset('linux-x64'), 'https://example.test/dl/'),
'https://example.test/dl/detector-v0.1.0/libimpeccable_detector-linux-x64.a',
);
assert.equal(DEFAULT_DETECTOR_BASE, 'https://github.com/pbakaus/impeccable/releases/download');
});
it('passes when every archive, checksum and the browser bundle answer', async () => {
const seen = [];
const fetchImpl = async (url) => { seen.push(url); return okResponse; };
const result = await checkDetectorRelease({ version: '0.1.0', base: 'https://example.test/dl', fetchImpl });
assert.equal(result.ok, true);
assert.equal(result.missing.length, 0);
assert.equal(seen.length, DETECTOR_TARGETS.length * 2 + 1);
assert.ok(seen.includes(`https://example.test/dl/detector-v0.1.0/${BROWSER_BUNDLE_ASSET}`));
});
it('lists every missing asset, sorted by target then archive/checksum/bundle', async () => {
const fetchImpl = async (url) => (url.includes('windows-x64') || url.endsWith(BROWSER_BUNDLE_ASSET) ? missingResponse : okResponse);
const result = await checkDetectorRelease({ version: '0.1.0', base: 'https://example.test/dl', fetchImpl });
assert.equal(result.ok, false);
assert.deepEqual(result.missing.map((m) => m.what), [
'impeccable_detector-windows-x64.lib',
'impeccable_detector-windows-x64.lib.sha256',
BROWSER_BUNDLE_ASSET,
]);
});
it('treats a network error as a missing asset instead of throwing', async () => {
const fetchImpl = async () => { throw new Error('offline'); };
const result = await checkDetectorRelease({ version: '0.1.0', base: 'https://example.test/dl', fetchImpl });
assert.equal(result.ok, false);
assert.equal(result.missing.length, DETECTOR_TARGETS.length * 2 + 1);
});
});
+39 -3
View File
@@ -52,7 +52,7 @@ function runRelease(cwd, ...args) {
// The D4 engine release-order guard would otherwise probe the network for
// published engine assets; these guards predate it and only exercise the
// version/changelog/artifact checks, so take its documented escape hatch.
env: { ...process.env, IMPECCABLE_SKIP_ENGINE_CHECK: '1' },
env: { ...process.env, IMPECCABLE_SKIP_ENGINE_CHECK: '1', IMPECCABLE_SKIP_DETECTOR_CHECK: '1' },
});
return { code: 0, stdout, stderr: '' };
} catch (err) {
@@ -88,12 +88,18 @@ describe('release.mjs guards', () => {
// (and check-engine-release.mjs imports fetch-engine.mjs), so stage them
// too or the dry runs fail to resolve the modules instead of exercising
// the guard.
for (const dep of ['check-engine-release.mjs', 'fetch-engine.mjs']) {
for (const dep of ['check-engine-release.mjs', 'check-detector-release.mjs', 'fetch-engine.mjs']) {
fs.copyFileSync(path.join(REPO_ROOT, 'scripts', dep), path.join(workDir, 'scripts', dep));
}
write('.claude-plugin/plugin.json', JSON.stringify({ name: 'impeccable', version: '1.2.3' }));
write('.claude-plugin/marketplace.json', JSON.stringify({ plugins: [{ name: 'impeccable', version: '1.2.3' }] }));
write('package.json', JSON.stringify({ name: 'impeccable', version: '9.9.9' }));
write('package.json', JSON.stringify({
name: 'impeccable',
version: '9.9.9',
optionalDependencies: { '@impeccable/cli-darwin-arm64': '0.1.0', '@impeccable/cli-linux-x64': '0.1.0' },
}));
write('ENGINE_VERSION', '0.1.0\n');
write('DETECTOR_VERSION', '0.1.0\n');
write('extension/manifest.json', JSON.stringify({ version: '2.0.0' }));
write('site/pages/changelog.astro', CHANGELOG);
write('dist/universal.zip', 'zip');
@@ -130,6 +136,36 @@ describe('release.mjs guards', () => {
}
});
it('dry-runs a clean engine release: tags only, CI publishes', () => {
const { code, stdout } = runRelease(workDir, 'engine');
assert.equal(code, 0, stdout);
assert.match(stdout, /Engine 0\.1\.0/);
assert.match(stdout, /2 platform package pins agree/);
assert.match(stdout, /Skipping detector release-order guard/);
assert.match(stdout, /\[dry-run\] git tag -a engine-v0\.1\.0/);
assert.match(stdout, /\[dry-run\] git push origin engine-v0\.1\.0/);
assert.doesNotMatch(stdout, /gh release create/);
assert.match(stdout, /release-engine workflow/);
});
it('engine: refuses when package.json platform pins disagree with ENGINE_VERSION', () => {
write('ENGINE_VERSION', '0.2.0\n');
git(workDir, 'commit', '-am', 'bump engine');
git(workDir, 'push', 'origin', 'main');
const { code, stderr } = runRelease(workDir, 'engine');
assert.notEqual(code, 0);
assert.match(stderr, /pins @impeccable\/cli-darwin-arm64@0\.1\.0.*expected 0\.2\.0/);
});
it('engine: refuses when the tag already exists on origin', () => {
git(workDir, 'tag', 'engine-v0.1.0');
git(workDir, 'push', 'origin', 'engine-v0.1.0');
git(workDir, 'tag', '-d', 'engine-v0.1.0');
const { code, stderr } = runRelease(workDir, 'engine');
assert.notEqual(code, 0);
assert.match(stderr, /engine-v0\.1\.0 already exists on origin/);
});
it('dry-runs a clean skill release end to end', () => {
const { code, stdout } = runRelease(workDir, 'skill');
assert.equal(code, 0, stdout);