From e355ebf7141401d9af0f7383956a4e7fa3371bf9 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Tue, 1 Sep 2026 14:05:39 -0700 Subject: [PATCH] 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), and the closed detector the engine links arrives as detector-v 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/. - 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 Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY --- .github/workflows/ci.yml | 99 ++++++++++++++++---- .github/workflows/release-engine.yml | 87 +++++++++++++++++ .gitignore | 3 + CLAUDE.md | 28 +++--- Cargo.toml | 42 +++++++++ DETECTOR_VERSION | 1 + cli/bin/cli.js | 4 +- docs/ENGINE.md | 130 ++++++++++++++++++++++++++ package.json | 5 +- rust-toolchain.toml | 8 ++ scripts/check-detector-release.mjs | 109 +++++++++++++++++++++ scripts/check-engine-release.mjs | 10 +- scripts/ci-test-plan.mjs | 16 +++- scripts/fetch-engine.mjs | 8 +- scripts/release.mjs | 110 ++++++++++++++++++++-- scripts/test-suites.mjs | 1 + skill/scripts/impeccable | 8 +- skill/scripts/impeccable.cmd | 8 +- tests/check-detector-release.test.mjs | 53 +++++++++++ tests/release.test.mjs | 42 ++++++++- 20 files changed, 714 insertions(+), 58 deletions(-) create mode 100644 .github/workflows/release-engine.yml create mode 100644 Cargo.toml create mode 100644 DETECTOR_VERSION create mode 100644 docs/ENGINE.md create mode 100644 rust-toolchain.toml create mode 100644 scripts/check-detector-release.mjs create mode 100644 tests/check-detector-release.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5876d5f71..1eefbb2c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 + # 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 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 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-- 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-- 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 diff --git a/.github/workflows/release-engine.yml b/.github/workflows/release-engine.yml new file mode 100644 index 000000000..850271a1a --- /dev/null +++ b/.github/workflows/release-engine.yml @@ -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` 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 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" diff --git a/.gitignore b/.gitignore index cb241e049..f741f30b9 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 8d7e268df..83c689c3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ` (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/-/impeccable[.exe]`, else `~/.impeccable/bin/impeccable`, else the version-pinned user cache `~/.impeccable/bin//`, 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`. `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=` points the build at a local detector build. +- **`ENGINE_VERSION`** (repo root) pins the engine release (`engine-v` 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//` 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//` (`bun run fetch:engine`; `IMPECCABLE_BIN= 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` to the `impeccable-dist` release channel: the five `impeccable--[.exe]` binaries plus a `.sha256` beside each. -2. Publish the five `@impeccable/cli--@` npm platform packages. -3. Only then tag/publish the skill or CLI release, and only then merge a branch that bumps `ENGINE_VERSION` (the `sync-generated-output.yml` workflow rewrites provider dirs on merge to `main`). +1. Publish detector `detector-v`: tag `v` 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`: `bun run release:engine` tags and pushes; `release-engine.yml` builds the five `impeccable--[.exe]` binaries plus a `.sha256` beside each and publishes the release on this repo. +3. Publish the five `@impeccable/cli--@` 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) diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 000000000..24103f114 --- /dev/null +++ b/Cargo.toml @@ -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" diff --git a/DETECTOR_VERSION b/DETECTOR_VERSION new file mode 100644 index 000000000..6e8bf73aa --- /dev/null +++ b/DETECTOR_VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/cli/bin/cli.js b/cli/bin/cli.js index 8536385ee..43c218ae9 100755 --- a/cli/bin/cli.js +++ b/cli/bin/cli.js @@ -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() { diff --git a/docs/ENGINE.md b/docs/ENGINE.md new file mode 100644 index 000000000..24f9e3a06 --- /dev/null +++ b/docs/ENGINE.md @@ -0,0 +1,130 @@ +# The engine: the Rust runtime behind every skill verb + +Every command the skill text runs is `{{scripts_path}}/impeccable `. 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/-/` (`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--.a` +(`impeccable_detector-windows-x64.lib`), published as the GitHub Release +`detector-v` 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=`: a directory holding the archive for the + current target (a local build of the detector repo). +2. `~/.impeccable/detector//-/` (`IMPECCABLE_HOME` + moves the root). +3. A download from `detector-v` 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`, published by the private repo's CI to this + repo's Releases). `DETECTOR_VERSION` here pins it. +2. **Engine** (`engine-v`): `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/`. +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. diff --git a/package.json b/package.json index c503159aa..b9d0ef6e9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 000000000..cf7603912 --- /dev/null +++ b/rust-toolchain.toml @@ -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"] diff --git a/scripts/check-detector-release.mjs b/scripts/check-detector-release.mjs new file mode 100644 index 000000000..16dbfcd1c --- /dev/null +++ b/scripts/check-detector-release.mjs @@ -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` 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(); +} diff --git a/scripts/check-engine-release.mjs b/scripts/check-engine-release.mjs index c8f92047d..0dce9ecdc 100644 --- a/scripts/check-engine-release.mjs +++ b/scripts/check-engine-release.mjs @@ -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 GitHub Release * release channel AND the five @impeccable/cli-- npm platform packages. * Nothing else mechanically stops a maintainer from tagging the skill release (or * merging and letting the sync workflow rewrite provider dirs) before those assets @@ -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-- 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)) { diff --git a/scripts/ci-test-plan.mjs b/scripts/ci-test-plan.mjs index f489917c5..2bc085b23 100644 --- a/scripts/ci-test-plan.mjs +++ b/scripts/ci-test-plan.mjs @@ -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}`, diff --git a/scripts/fetch-engine.mjs b/scripts/fetch-engine.mjs index 0ea68ad31..ae640be5f 100644 --- a/scripts/fetch-engine.mjs +++ b/scripts/fetch-engine.mjs @@ -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: /v/impeccable--[.exe], + * The URL scheme is the launcher's: /engine-v/impeccable--[.exe], * with an optional .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')) { diff --git a/scripts/release.mjs b/scripts/release.mjs index e400dd975..ed5963905 100755 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -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 [--dry-run] +// Usage: node scripts/release.mjs [--dry-run] +// +// `engine` is different: it only tags `engine-v` 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 [--dry-run]'); + console.error('usage: release.mjs [--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-- +// live: the five engine-v release binaries + .sha256 and the five @impeccable/cli-- // npm platform packages. Without this the launcher, the npm shim, and // `impeccable install` all dead-end. Set IMPECCABLE_SKIP_ENGINE_CHECK=1 only // when you know the assets exist and the registry probe is unreachable. @@ -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--\n` + + ` Publish engine v${engineVersion} (bun run release:engine) AND the five @impeccable/cli--\n` + ' npm platform packages first. Ordering: engine release → platform packages → skill/CLI release.\n' + ' See CLAUDE.md "Releases" and the engine repo docs/REVIEW-TRIAGE.md D4.' ); @@ -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 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-- npm platform packages, then release the CLI/skill.'); +} diff --git a/scripts/test-suites.mjs b/scripts/test-suites.mjs index c8c0d7961..2c6509599 100644 --- a/scripts/test-suites.mjs +++ b/scripts/test-suites.mjs @@ -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', diff --git a/skill/scripts/impeccable b/skill/scripts/impeccable index 9e3b1f743..0c3e351a8 100755 --- a/skill/scripts/impeccable +++ b/skill/scripts/impeccable @@ -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 diff --git a/skill/scripts/impeccable.cmd b/skill/scripts/impeccable.cmd index f9a843ed7..be100381a 100644 --- a/skill/scripts/impeccable.cmd +++ b/skill/scripts/impeccable.cmd @@ -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 diff --git a/tests/check-detector-release.test.mjs b/tests/check-detector-release.test.mjs new file mode 100644 index 000000000..ea242f218 --- /dev/null +++ b/tests/check-detector-release.test.mjs @@ -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); + }); +}); diff --git a/tests/release.test.mjs b/tests/release.test.mjs index 63cdc8d99..4f14ad2fc 100644 --- a/tests/release.test.mjs +++ b/tests/release.test.mjs @@ -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);