mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
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:
co-authored by
Claude Fable 5.1
parent
6c474b1c79
commit
e355ebf714
+82
-17
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
0.1.0
|
||||
+2
-2
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user