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
+82 -17
View File
@@ -23,6 +23,7 @@ jobs:
runs-on: ubuntu-latest
outputs:
core: ${{ steps.plan.outputs.core }}
rust: ${{ steps.plan.outputs.rust }}
detector: ${{ steps.plan.outputs.detector }}
live: ${{ steps.plan.outputs.live }}
framework: ${{ steps.plan.outputs.framework }}
@@ -118,25 +119,81 @@ jobs:
- name: Upload build artifacts
uses: actions/upload-artifact@v7
with:
name: impeccable-dist-node-${{ matrix.node-version }}
name: impeccable-build-node-${{ matrix.node-version }}
# Ship the packaged zips, not the unpacked Firefox staging tree.
path: |
dist/
!dist/extension-firefox/
retention-days: 7
# Behavior gate: replays the tests/oracle/ goldens against the pinned
# engine binary (ENGINE_VERSION). Without this job the oracle only ever
# The Rust workspace: the engine binary and every crate behind it.
# crates/core/build.rs downloads the prebuilt closed detector archive for
# the pinned DETECTOR_VERSION (docs/ENGINE.md), so this job needs that
# release to exist.
#
# continue-on-error is a release-time toggle: until detector-v<DETECTOR_VERSION>
# is published, the archive cannot be fetched and the job would block every
# PR. Once it is live, flip continue-on-error to false so a Rust regression
# fails CI instead of only annotating it.
rust:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.rust == 'true'
continue-on-error: true
steps:
- name: Checkout repository
uses: actions/checkout@v7
# rust-toolchain.toml pins the exact rustc the detector was built with;
# `rustup show` installs it. Never override the toolchain here.
- name: Install the pinned toolchain
run: rustup show
- uses: Swatinem/rust-cache@v2
- name: Build
id: build
continue-on-error: true
run: cargo build --workspace --all-targets
- name: Test
if: steps.build.outcome == 'success'
run: cargo test --workspace
- name: Annotate missing detector release
if: steps.build.outcome != 'success'
run: |
echo "::warning title=Rust workspace not built::cargo build failed. If crates/core/build.rs could not download detector-v$(cat DETECTOR_VERSION), the detector release is not published yet; publish it (tag the private detector repo) and flip this job's continue-on-error to false. Otherwise this is a real build failure."
exit 1
# The engine ships a windows-x64 binary (release-engine.yml), so the
# workspace has to build and pass its own tests there. Tests that need a
# browser or the oracle skip when those are absent.
rust-windows:
runs-on: windows-latest
needs: changes
if: needs.changes.outputs.rust == 'true'
continue-on-error: true
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Install the pinned toolchain
run: rustup show
- uses: Swatinem/rust-cache@v2
- run: cargo build --workspace --all-targets
- run: cargo test --workspace
# Behavior gate: replays the tests/oracle/ goldens against a release build
# of the engine from THIS checkout (so a PR is judged on its own source,
# not on the last published binary). Without this job the oracle only ever
# runs on developer laptops: tests/oracle.test.mjs skips cleanly when no
# binary is present, so the default suite is silent about it on CI.
#
# continue-on-error is a release-time toggle: until the first engine
# release is published to impeccable-dist, `bun run fetch:engine` 404s and
# the job would block every PR on an asset that cannot exist yet. Once
# v<ENGINE_VERSION> is live, flip `continue-on-error` to false so oracle
# regressions fail CI instead of only annotating it.
# continue-on-error: same release-time toggle as `rust` above.
oracle:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.oracle == 'true' || needs.changes.outputs.rust == 'true'
continue-on-error: true
steps:
- name: Checkout repository
@@ -155,19 +212,27 @@ jobs:
- name: Install dependencies
run: bun install
- name: Fetch pinned engine binary
id: fetch
- name: Install the pinned toolchain
run: rustup show
- uses: Swatinem/rust-cache@v2
- name: Build the engine from source
id: build
continue-on-error: true
run: bun run fetch:engine
run: cargo build --release -p impeccable
- name: Replay oracle goldens
if: steps.fetch.outcome == 'success'
if: steps.build.outcome == 'success'
env:
IMPECCABLE_BIN: ${{ github.workspace }}/target/release/impeccable
run: node tests/oracle/run.mjs
- name: Annotate missing engine release
if: steps.fetch.outcome != 'success'
- name: Annotate missing detector release
if: steps.build.outcome != 'success'
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."
echo "::warning title=Oracle not run::the engine did not build (see the rust job). The oracle behavior gate did NOT run. Expected until detector-v$(cat DETECTOR_VERSION) is published; after that, flip this job's continue-on-error to false."
exit 1
# Release-order guard (triage decision D4). Verifies that the engine release for
# the pinned ENGINE_VERSION is fully published — the five dist binaries + .sha256
@@ -176,7 +241,7 @@ jobs:
# `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
# published, the assets cannot exist and this job would block
# every PR. It emits a loud ::warning instead. Once v<ENGINE_VERSION> 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`.
@@ -200,7 +265,7 @@ jobs:
- 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-<os>-<arch> 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."
echo "::warning title=Engine release not ready::The engine release for v$(cat ENGINE_VERSION) is not fully published (engine-v$(cat ENGINE_VERSION) release) and/or the @impeccable/cli-<os>-<arch> 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
+87
View File
@@ -0,0 +1,87 @@
name: release-engine
# Builds the engine binary for every supported target and publishes them, with
# sha256 sidecars, as the GitHub Release `engine-v<X>` on this repo. That
# release is what the launcher (skill/scripts/impeccable), the npm shim
# (cli/bin/cli.js), `impeccable install`, and `bun run fetch:engine` download.
#
# Trigger: `bun run release:engine` (scripts/release.mjs) verifies
# ENGINE_VERSION, the detector release it builds against, and a clean tree,
# then pushes the tag. Third-party actions are pinned to commit SHAs so a
# moved tag cannot swap the code this workflow runs.
on:
push:
tags: ['engine-v*']
permissions:
contents: write
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- { os: macos-14, target: aarch64-apple-darwin, short: darwin-arm64 }
- { os: macos-13, target: x86_64-apple-darwin, short: darwin-x64 }
- { os: ubuntu-latest, target: x86_64-unknown-linux-musl, short: linux-x64 }
- { os: ubuntu-latest, target: aarch64-unknown-linux-musl, short: linux-arm64, cross: true }
- { os: windows-latest, target: x86_64-pc-windows-msvc, short: windows-x64 }
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Check the tag matches ENGINE_VERSION
shell: bash
run: |
set -e
want="engine-v$(tr -d '[:space:]' < ENGINE_VERSION)"
[ "$GITHUB_REF_NAME" = "$want" ] || { echo "tag $GITHUB_REF_NAME != $want"; exit 1; }
# rust-toolchain.toml pins the exact rustc the prebuilt detector was
# built with; `rustup show` installs it. Never override the toolchain here.
- name: Install the pinned toolchain
shell: bash
run: rustup show && rustup target add ${{ matrix.target }}
- if: matrix.os == 'ubuntu-latest'
run: sudo apt-get update && sudo apt-get install -y musl-tools
- if: matrix.cross
run: cargo install cross --locked
- name: Build
shell: bash
# build.rs in crates/core downloads the pinned detector archive for
# this target from the detector-v<DETECTOR_VERSION> release.
run: ${{ matrix.cross && 'cross' || 'cargo' }} build --release -p impeccable --target ${{ matrix.target }}
- name: Smoke the binary
if: ${{ !matrix.cross }}
shell: bash
run: target/${{ matrix.target }}/release/impeccable${{ runner.os == 'Windows' && '.exe' || '' }} engine-probe
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: impeccable-${{ matrix.short }}
path: target/${{ matrix.target }}/release/impeccable${{ runner.os == 'Windows' && '.exe' || '' }}
if-no-files-found: error
publish:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with: { path: artifacts }
- name: Lay out release assets with checksums
run: |
set -e
mkdir -p out
for d in artifacts/impeccable-*; do
short=$(basename "$d" | sed 's/^impeccable-//')
f=$(ls "$d" | head -1)
case "$short" in windows-*) dest="out/impeccable-$short.exe" ;; *) dest="out/impeccable-$short" ;; esac
cp "$d/$f" "$dest"
(cd out && sha256sum "$(basename "$dest")" > "$(basename "$dest").sha256")
done
ls -la out
- name: Publish the GitHub Release
env: { GH_TOKEN: "${{ github.token }}" }
# No --clobber: a published asset is immutable. A re-run against an
# existing release fails on the first existing asset instead of
# silently replacing a binary and its sidecar hash.
run: |
set -e
tag="${GITHUB_REF_NAME}"
gh release create "$tag" --repo "$GITHUB_REPOSITORY" --title "impeccable engine $tag" \
--notes "Prebuilt impeccable engine binaries ($tag). The launcher, the npm shim and impeccable install download these on first run. Docs: https://impeccable.style" out/* || \
gh release upload "$tag" out/* --repo "$GITHUB_REPOSITORY"
+3
View File
@@ -17,6 +17,9 @@ build/
# Build artifacts
*.log
# Cargo (the Rust workspace; Cargo.lock IS tracked, it pins the engine build)
/target/
# OS files
.DS_Store
Thumbs.db
+17 -11
View File
@@ -14,9 +14,10 @@ There is **one** user-invocable skill, `impeccable`, with **23 commands** undern
The skill has no runtime of its own. Every command the skill text runs is `{{scripts_path}}/impeccable <verb>` (Setup step 1 says `impeccable context`; `impeccable.cmd` is the Windows twin for shells without `sh`). `skill/scripts/impeccable` is a POSIX `sh` launcher: it execs `$IMPECCABLE_BIN` if set, else the sibling `scripts/bin/<os>-<arch>/impeccable[.exe]`, else `~/.impeccable/bin/impeccable`, else the version-pinned user cache `~/.impeccable/bin/<VERSION>/`, else `impeccable` on PATH, and as a last resort downloads the pinned version into that cache. It exports `IMPECCABLE_SKILL_DIR` (the skill dir, for `reference/*.md` and `command-metadata.json`) and `IMPECCABLE_SELF` (how the binary spells itself in the commands it prints).
The binary is built from a separate repo (`~/code/impeccable-engine`; do not edit it from here). Its verbs are the old script basenames (`context`, `doctor`, `pin`, `hook`, `hook-before-edit`, `live*`, `detect`, ...) with two aliases: `signals` for context-signals and `hooks` for hook-admin. Its observable behavior is specified in `docs/CLI-CONTRACT.md` and pinned by `tests/oracle/`.
The binary is built from **this repo's Cargo workspace** (`Cargo.toml` at the root, `crates/*`; `cargo build --release -p impeccable`). Its verbs are the old script basenames (`context`, `doctor`, `pin`, `hook`, `hook-before-edit`, `live*`, `detect`, ...) with two aliases: `signals` for context-signals and `hooks` for hook-admin. Its observable behavior is specified in `docs/CLI-CONTRACT.md` and pinned by `tests/oracle/`. **Read `docs/ENGINE.md` before touching `crates/`**: it maps the crates and explains the one piece that is not in this repo.
- **`ENGINE_VERSION`** (repo root) pins the engine release. The build copies it to `skill/scripts/VERSION`, which the launcher reads to name the download and the cache dir; `cli/bin/cli.js` reads the same version from `package.json`'s `optionalDependencies`. Bumping it is a release-time decision, like the other manifest versions.
- **The rule engine is closed and prebuilt.** The checks themselves (the "detector") live in the private repo `renaissance-geek-inc/impeccable-detector` and ship as a native archive per target, published to this repo's GitHub Releases as `detector-v<DETECTOR_VERSION>`. `crates/core/build.rs` downloads and links it; `crates/foundation` (open) holds the helpers and every type that crosses the boundary; `crates/core` is the shim that keeps the `impeccable_core::...` paths every crate uses. Consequences: `rust-toolchain.toml` pins an **exact** rustc (the archive's objects only link against the same build), the release profile has `lto = false` (cross-crate LTO drops std symbols the archive needs), and a rule change is a detector release plus a `DETECTOR_VERSION` bump here. `IMPECCABLE_DETECTOR_LIB=<dir>` points the build at a local detector build.
- **`ENGINE_VERSION`** (repo root) pins the engine release (`engine-v<X>` on this repo's GitHub Releases, built by `.github/workflows/release-engine.yml` when `bun run release:engine` pushes the tag). The build copies it to `skill/scripts/VERSION`, which the launcher reads to name the download and the cache dir; `cli/bin/cli.js` reads the same version from `package.json`'s `optionalDependencies`. Bumping it is a release-time decision, like the other manifest versions. **`DETECTOR_VERSION`** pins the closed detector the engine is built against; it moves independently.
- **Binaries are never tracked.** `skill/scripts/bin/` and `**/skills/impeccable/scripts/bin/` are gitignored, so the tracked provider dirs and `plugin/` ship launcher-only and users get the binary on first run. `bun run build:release` produces launcher-only zips by default; `IMPECCABLE_BUNDLE_ENGINE=1 bun run build:release` fetches every target (`scripts/fetch-engine.mjs --all --lenient`) and stages `bin/<os-arch>/` into the dist skill copies **after** the root harness dirs and `plugin/` were synced, so `dist/universal.zip` is self-contained for offline installs while git stays clean. Bundling is opt-in because five targets in every provider copy put `universal.zip` near 340 MB, past the 25 MB Cloudflare Pages file cap that `impeccable install` downloads through.
- **Tests get a binary** from `IMPECCABLE_BIN` or `skill/scripts/bin/<os-arch>/` (`bun run fetch:engine`; `IMPECCABLE_BIN=<local build> bun run fetch:engine` copies a local build there). `tests/lib/engine-bin.mjs` is the one resolver; suites that need the binary skip cleanly without it.
- **The oracle is the behavior gate.** `tests/oracle/` holds goldens recorded from the JS scripts before they left the tree, plus reviewed deltas in `DELTAS.md`; `tests/oracle.test.mjs` replays them against the binary in `bun run test`. New cases are recorded from the binary (`record.mjs --bin`) and reviewed by hand. `tests/oracle/vectors/calls/` is the frozen function-level snapshot; it cannot be regenerated.
@@ -290,13 +291,14 @@ If you need to fix release notes after the fact (typo, missing thank-you, format
### 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):**
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 detector release, then the engine release, then the platform packages, then release/merge the skill (or CLI):**
1. Publish engine `v<ENGINE_VERSION>` to the `impeccable-dist` release channel: the five `impeccable-<os>-<arch>[.exe]` binaries plus a `.sha256` beside each.
2. Publish the five `@impeccable/cli-<os>-<arch>@<ENGINE_VERSION>` 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`).
1. Publish detector `detector-v<DETECTOR_VERSION>`: tag `v<X>` in the private detector repo; its CI uploads the five archives + `.sha256` and `detector-browser-bundle.zip` to this repo's Releases. `scripts/check-detector-release.mjs` verifies it; `bun run release:engine` refuses without it (the engine build downloads the archive for every target).
2. Publish engine `engine-v<ENGINE_VERSION>`: `bun run release:engine` tags and pushes; `release-engine.yml` builds the five `impeccable-<os>-<arch>[.exe]` binaries plus a `.sha256` beside each and publishes the release on this repo.
3. Publish the five `@impeccable/cli-<os>-<arch>@<ENGINE_VERSION>` npm platform packages.
4. 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.
`scripts/check-engine-release.mjs` verifies step 2 and 3 for the pinned version (ranged-GET each release 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
@@ -306,7 +308,7 @@ All commands live under `/impeccable`. To add a new one:
2. Add a row to the **Sub-command reference table** in `skill/SKILL.src.md`
3. Add an entry to the **Command menu** section in the same file
4. Add the command name to `IMPECCABLE_SUB_COMMANDS` in `scripts/lib/utils.js`
5. Add it to the `pin` verb's valid-command list in the engine repo (and record the pin/unpin oracle case)
5. Add it to the `pin` verb's valid-command list (`crates/context`) and record the pin/unpin oracle case
6. Add its metadata (description + argumentHint) to `skill/scripts/command-metadata.json`
7. Add its category to `SKILL_CATEGORIES` in `scripts/lib/skill-categories.js`
8. Add its relationships to `COMMAND_RELATIONSHIPS` in impeccable-site's `sub-pages-data.js`
@@ -324,17 +326,21 @@ The build validator (`generateCounts` in `scripts/build.js`) checks these files
## Adding or modifying anti-pattern detection rules
The rule engine lives in the engine repo (`crates/core` is the pure rule core, `crates/html` the static HTML engine, `crates/browser` the URL engine, plus a WASM build for the extension, the live overlay, and the site). Nothing here implements a rule. What this repo owns and keeps in sync:
The rule engine lives in the private detector repo (`renaissance-geek-inc/impeccable-detector`, checked out at `~/code/impeccable-detector`): the checks, the browser rule adapters over the `Dom` trait, the visual-contrast decisions, and the wasm build for the extension, the live overlay and the site. This repo owns everything around it and keeps it in sync:
| Where | How it stays in sync |
|---|---|
| `docs/CLI-CONTRACT.md` | Hand-edited: the observable contract of `impeccable detect` and every other verb |
| `crates/foundation` | Open: the rule registry (`registry.rs`, already public as `antipatterns.json`), findings, color, the `Dom` trait, `SnapshotDom`, and every type that crosses the boundary (`boundary.rs` holds the function ids) |
| `crates/core` | The shim: one function per closed check the open crates call; a test diffs its id table against the archive's |
| `crates/html`, `crates/browser`, `crates/detect` | Open engines: parsing, cascade, CDP, snapshots, file walking, output. They call the checks through `impeccable_core::checks::*` and `impeccable_core::browser::*` |
| `tests/fixtures/antipatterns/{rule-id}.html` | Hand-edited fixture (two columns, should-flag / should-pass, unique headings, explicit pixel dimensions) |
| `tests/oracle/golden/*` | Recorded from the binary with `node tests/oracle/record.mjs --bin detect-`, reviewed by hand |
| `extension/detector/` | Vendored from the engine by `bun run build:extension`; the build's rule-count check reads `antipatterns.json` when present |
| `tests/oracle/vectors/calls/` | Frozen function-level vectors; replay through the shipped archive via `impeccable_core::vectors::call` |
| `extension/detector/` | Vendored from `detector-browser-bundle.zip` by `bun run build:extension`; the build's rule-count check reads `antipatterns.json` when present |
| `skill/SKILL.src.md` and `reference/*.md` | Hand-edited if the rule introduces new design guidance |
Order for a new rule: fixture here first, rule in the engine against that fixture, oracle case + golden here, then `bun run build && bun run test` with a binary present. Rule counts quoted in `README.md` / `README.npm.md` are validated by `generateCounts` against the vendored registry.
Order for a new rule: fixture here first, registry row in `crates/foundation/src/registry.rs`, the check in the detector repo against that fixture (plus a `boundary.rs` id and a shim when an open crate calls it directly), a detector release and `DETECTOR_VERSION` bump, oracle case + golden here, then `bun run build && bun run test` with a binary present. Rule counts quoted in `README.md` / `README.npm.md` are validated by `generateCounts` against the vendored registry.
## Evals Framework (separate private repo)
+42
View File
@@ -0,0 +1,42 @@
# The impeccable runtime: one Cargo workspace next to the skill it powers.
# `cargo build --release -p impeccable` produces the engine binary the launcher
# (skill/scripts/impeccable) runs. See docs/ENGINE.md.
[workspace]
resolver = "2"
members = ["crates/*"]
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "Apache-2.0"
publish = false
[workspace.dependencies]
impeccable-foundation = { path = "crates/foundation" }
impeccable-common = { path = "crates/common" }
impeccable-core = { path = "crates/core" }
impeccable-detect = { path = "crates/detect" }
impeccable-html = { path = "crates/html" }
impeccable-browser = { path = "crates/browser" }
impeccable-live = { path = "crates/live" }
impeccable-context = { path = "crates/context" }
impeccable-hook = { path = "crates/hook" }
impeccable-comp = { path = "crates/comp" }
impeccable-comp-verbs = { path = "crates/comp-verbs" }
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["preserve_order"] }
anyhow = "1"
thiserror = "2"
regex = "1"
once_cell = "1"
# No cross-crate LTO: crates/core links the prebuilt detector as a native
# archive of Rust objects, and fat or thin LTO internalizes the std symbols
# those opaque objects still reference (the link then fails with "symbol(s)
# not found"). Cargo's default thin-local LTO is what `lto = false` means.
[profile.release]
opt-level = 3
lto = false
codegen-units = 1
strip = true
panic = "abort"
+1
View File
@@ -0,0 +1 @@
0.1.0
+2 -2
View File
@@ -21,8 +21,8 @@ const PLATFORM_PKG = `@impeccable/cli-${TARGET}`;
const VERSION = String(pkg.optionalDependencies?.[PLATFORM_PKG] || Object.values(pkg.optionalDependencies || {})[0] || '').replace(/^[^\d]*/, '');
const CACHE_ROOT = process.env.IMPECCABLE_HOME || path.join(os.homedir(), '.impeccable');
const CACHED = path.join(CACHE_ROOT, 'bin', VERSION, EXE);
const BASE = (process.env.IMPECCABLE_DOWNLOAD_BASE || 'https://github.com/renaissance-geek-inc/impeccable-dist/releases/download').replace(/\/$/, '');
const URL = `${BASE}/v${VERSION}/impeccable-${TARGET}${OS === 'windows' ? '.exe' : ''}`;
const BASE = (process.env.IMPECCABLE_DOWNLOAD_BASE || 'https://github.com/pbakaus/impeccable/releases/download').replace(/\/$/, '');
const URL = `${BASE}/engine-v${VERSION}/impeccable-${TARGET}${OS === 'windows' ? '.exe' : ''}`;
function exists(p) { try { return !!p && fs.statSync(p).isFile(); } catch { return false; } }
function fromPackage() {
+130
View File
@@ -0,0 +1,130 @@
# The engine: the Rust runtime behind every skill verb
Every command the skill text runs is `{{scripts_path}}/impeccable <verb>`. The
launcher next to the skill (`skill/scripts/impeccable`, `impeccable.cmd`)
finds or downloads one static binary per platform and execs it. That binary
is built from this repo's Cargo workspace. There is no Node at runtime.
This page is the map for anyone building or changing the runtime. The
observable behavior of every verb is specified in `CLI-CONTRACT.md` and
pinned byte-for-byte by `tests/oracle/`.
## Layout
```
Cargo.toml the workspace (crates/*), release profile
rust-toolchain.toml EXACT rustc pin (see "The closed detector")
DETECTOR_VERSION which prebuilt detector release crates/core links
ENGINE_VERSION which engine release the launcher / npm shim download
crates/
cli the `impeccable` binary: verb router, exit codes
common Io handle (stdout/stderr/stdin/env/cwd), path + process helpers
context context, doctor, staleness, signals, concept-seed, pin, ...
hook the design hook (hook, hook-before-edit, hook-admin)
live live mode: server, wrap, accept, manual edits, Svelte/Vue
skills install / update / check / link (the old npm CLI verbs)
comp comp-fidelity pure libs (raster, png, metrics, fonts)
comp-verbs build-phase, comp-diff, comp-spec, font-match
detect `impeccable detect`: file walk, config, ignores, output, regex engine
html the static HTML engine: parser, cascade, static DOM, rule adapters
browser the URL engine: Chrome discovery, CDP, snapshot, visual pass
foundation OPEN helpers + boundary types: JS-semantics helpers, color,
findings, registry, inline ignores, the Dom trait, SnapshotDom
core the shim: re-exports foundation under the paths every crate
uses, and forwards the rule checks to the closed detector
```
Build and test:
```bash
cargo build --release -p impeccable # target/release/impeccable
cargo test --workspace
IMPECCABLE_BIN=target/release/impeccable node tests/oracle/run.mjs # the behavior gate
```
`bun run test` and the oracle find the binary through `IMPECCABLE_BIN` or
`skill/scripts/bin/<os>-<arch>/` (`bun run fetch:engine` downloads the pinned
release there; `IMPECCABLE_BIN=target/release/impeccable bun run fetch:engine`
copies a local build).
## The closed detector
The rule engine itself (the checks, the browser rule adapters, the visual
contrast decisions) is proprietary and lives in a private repo. It ships as a
prebuilt native archive per target, `libimpeccable_detector-<os>-<arch>.a`
(`impeccable_detector-windows-x64.lib`), published as the GitHub Release
`detector-v<DETECTOR_VERSION>` on this repo, next to
`detector-browser-bundle.zip` (the same rules compiled to wasm for the
extension, the live overlay and the site).
`crates/core/build.rs` resolves the archive in this order and links it:
1. `IMPECCABLE_DETECTOR_LIB=<dir>`: a directory holding the archive for the
current target (a local build of the detector repo).
2. `~/.impeccable/detector/<DETECTOR_VERSION>/<os>-<arch>/` (`IMPECCABLE_HOME`
moves the root).
3. A download from `detector-v<DETECTOR_VERSION>` into that cache, verified
against the `.sha256` sidecar. `IMPECCABLE_DETECTOR_BASE` overrides the
release root; `IMPECCABLE_DETECTOR_OFFLINE=1` refuses to download.
Three things follow from how that archive is made, and they are the reason
for three otherwise odd-looking settings:
- **The toolchain is pinned to an exact version.** The archive is the closed
crates' rlib objects repacked with `llvm-ar`, with no std inside. Its
objects reference std by mangled symbol name, which only resolves against
the same rustc build. `rust-toolchain.toml` pins it; rustup installs it on
first `cargo` invocation. A toolchain bump needs a new detector release.
- **The release profile has `lto = false`.** Fat and thin LTO internalize std
symbols the opaque archive still needs and the link fails with "symbol(s)
not found". Cargo's default thin-local LTO stays.
- **`crates/core` keeps the old paths.** Nothing outside `crates/core` and
`crates/foundation` knows about the boundary: `impeccable_core::checks::
rules::check_colors` is a one-line shim that encodes its argument, calls
the archive, decodes the result. The C-ABI is three symbols
(`det_abi_version`, `det_call`, `det_free`) and a host vtable the closed
side uses to call back into the open `Dom` / `StyleMap` implementations.
Every id and every type that crosses is declared in
`crates/foundation/src/boundary.rs`; the shim checks the ABI number once
and panics with a clear message when the archive was built for another.
The frozen function-level vectors in `tests/oracle/vectors/calls/` replay
through the shipped archive (`impeccable_core::vectors::call` forwards
unknown names to it), so the black box is verified the same way the open
code is.
## Releases
Three release kinds touch the runtime, in this order:
1. **Detector** (`detector-v<X>`, published by the private repo's CI to this
repo's Releases). `DETECTOR_VERSION` here pins it.
2. **Engine** (`engine-v<ENGINE_VERSION>`): `bun run release:engine` verifies
the detector release exists (`scripts/check-detector-release.mjs`), tags,
and pushes; `.github/workflows/release-engine.yml` builds the five targets
and publishes the binaries with `.sha256` sidecars. The launcher, the npm
shim and `impeccable install` download from
`github.com/pbakaus/impeccable/releases/download/engine-v<X>/`.
3. **npm platform packages**, then the **skill** and **CLI** releases, which
`scripts/check-engine-release.mjs` gates on the engine release.
CI runs the workspace build and tests (`rust`, `rust-windows`) and the oracle
against a source build; both are warn-only until the first detector release
exists, then their `continue-on-error` flips to false.
## Working on the detector
Changes to rule logic happen in the private detector repo. Point the shim at
a local build while iterating:
```bash
# in the detector repo
cargo xtask detector-archive --out /tmp/det
# here
IMPECCABLE_DETECTOR_LIB=/tmp/det cargo test --workspace
```
Adding a function that the open crates call: add its id to
`foundation/src/boundary.rs` (never renumber), the shim in `crates/core`, the
dispatcher arm in the detector repo, and bump `boundary::ABI` if any existing
signature or type changed. The shim's test diffs the two id tables.
+4 -1
View File
@@ -65,7 +65,10 @@
"postpack": "cp README.repo.md README.md && rm README.repo.md",
"release:skill": "node scripts/release.mjs skill",
"release:cli": "node scripts/release.mjs cli",
"release:ext": "node scripts/release.mjs extension"
"release:ext": "node scripts/release.mjs extension",
"release:engine": "node scripts/release.mjs engine",
"check:detector-release": "node scripts/check-detector-release.mjs",
"check:engine-release": "node scripts/check-engine-release.mjs"
},
"optionalDependencies": {
"@impeccable/cli-darwin-arm64": "0.1.0",
+8
View File
@@ -0,0 +1,8 @@
# Exact pin, on purpose. crates/core links a prebuilt closed detector
# (docs/ENGINE.md) whose objects reference std by mangled symbol name, so the
# runtime and the detector must be compiled by the very same rustc. rustup
# installs this version automatically; bumping it means a new detector
# release built with the new version (DETECTOR_VERSION moves with it).
[toolchain]
channel = "1.97.1"
targets = ["wasm32-unknown-unknown"]
+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',
+4 -4
View File
@@ -101,10 +101,10 @@ if [ -n "$probing" ]; then
exit 127
fi
if [ -n "$version" ] && [ "$os" != unknown ] && [ "$arch" != unknown ]; then
base="${IMPECCABLE_DOWNLOAD_BASE:-https://github.com/renaissance-geek-inc/impeccable-dist/releases/download}"
base="${IMPECCABLE_DOWNLOAD_BASE:-https://github.com/pbakaus/impeccable/releases/download}"
asset="impeccable-$os-$arch"
[ "$os" = windows ] && asset="$asset.exe"
url="$base/v$version/$asset"
url="$base/engine-v$version/$asset"
tmp="$cache_root/bin/$version/.impeccable.part.$$"
mkdir -p "$cache_root/bin/$version" 2>/dev/null
fetched=0
@@ -112,7 +112,7 @@ if [ -n "$version" ] && [ "$os" != unknown ] && [ "$arch" != unknown ]; then
fetched=1
elif [ "$os" = windows ] && [ "$arch" = arm64 ]; then
# Windows on ARM runs x64 binaries; fall back when no arm64 asset exists.
url="$base/v$version/impeccable-windows-x64.exe"
url="$base/engine-v$version/impeccable-windows-x64.exe"
fetch_url "$url" && fetched=1
fi
if [ "$fetched" = 1 ]; then
@@ -150,5 +150,5 @@ if [ -n "$version" ] && [ "$os" != unknown ] && [ "$arch" != unknown ]; then
fi
echo "impeccable: no engine binary for $os-$arch found (looked in $bin, $cached, PATH)." >&2
echo "Download impeccable-$os-$arch from https://github.com/renaissance-geek-inc/impeccable-dist/releases into $cache_root/bin/$version/impeccable$exe (then chmod +x), or set IMPECCABLE_BIN to a preinstalled engine binary. Docs: https://impeccable.style" >&2
echo "Download impeccable-$os-$arch from https://github.com/pbakaus/impeccable/releases (tag engine-v$version) into $cache_root/bin/$version/impeccable$exe (then chmod +x), or set IMPECCABLE_BIN to a preinstalled engine binary. Docs: https://impeccable.style" >&2
exit 127
+4 -4
View File
@@ -69,15 +69,15 @@ if defined IMPECCABLE_LAUNCHER_PROBE exit /b 127
if not defined version goto fail
where curl.exe >nul 2>nul
if errorlevel 1 goto fail
if not defined IMPECCABLE_DOWNLOAD_BASE set "IMPECCABLE_DOWNLOAD_BASE=https://github.com/renaissance-geek-inc/impeccable-dist/releases/download"
if not defined IMPECCABLE_DOWNLOAD_BASE set "IMPECCABLE_DOWNLOAD_BASE=https://github.com/pbakaus/impeccable/releases/download"
if not exist "%IMPECCABLE_HOME%\bin\%version%" mkdir "%IMPECCABLE_HOME%\bin\%version%" >nul 2>nul
set "asset=impeccable-windows-%arch%.exe"
set "url=%IMPECCABLE_DOWNLOAD_BASE%/v%version%/%asset%"
set "url=%IMPECCABLE_DOWNLOAD_BASE%/engine-v%version%/%asset%"
curl.exe -fsSL -o "%cached%.part" "%url%" >nul 2>nul
if not errorlevel 1 goto verify
if not "%arch%"=="arm64" goto fail
set "asset=impeccable-windows-x64.exe"
set "url=%IMPECCABLE_DOWNLOAD_BASE%/v%version%/%asset%"
set "url=%IMPECCABLE_DOWNLOAD_BASE%/engine-v%version%/%asset%"
curl.exe -fsSL -o "%cached%.part" "%url%" >nul 2>nul
if errorlevel 1 goto fail
@@ -138,5 +138,5 @@ exit /b 0
:fail
del "%cached%.part" >nul 2>nul
echo impeccable: no engine binary found (looked in %bin%, %cached%, PATH). 1>&2
echo Download impeccable-windows-%arch%.exe from https://github.com/renaissance-geek-inc/impeccable-dist/releases and save it as %cached%, or set IMPECCABLE_BIN to a preinstalled engine binary. Docs: https://impeccable.style 1>&2
echo Download impeccable-windows-%arch%.exe from https://github.com/pbakaus/impeccable/releases (tag engine-v%version%) and save it as %cached%, or set IMPECCABLE_BIN to a preinstalled engine binary. Docs: https://impeccable.style 1>&2
exit /b 127
+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);