Files
pbakaus_impeccable/.github/workflows/ci.yml
T
Paul BakausandClaude Fable 5.1 4369ad538d Open the detector: the rule crates join the workspace, the C-ABI goes away
The detector is open source. The rules it ships were already public in this
repo's git history and in every npm tarball of the JS engine, so a closed
binary bought nothing it could keep; the moat is the service (the catalog,
the labs, the review pipeline), not the check functions. Keeping them behind
a prebuilt archive cost a C-ABI, an exact toolchain pin, a build-time
download, a second release to order ahead of every engine release, and a
serde layer that had to serve two encodings.

Deleted
- crates/core/src/ffi.rs, crates/core/build.rs, crates/core/tests/boundary.rs
  and the shim modules under src/checks and src/browser.
- crates/foundation/src/boundary.rs and the postcard dependency.
- DETECTOR_VERSION, scripts/check-detector-release.mjs and its test, the
  check:detector-release script, the detector gate and
  IMPECCABLE_SKIP_DETECTOR_CHECK in scripts/release.mjs.
- scripts/lib/detector-bundle.mjs and tests/detector-bundle.test.mjs (the
  vendoring path for the closed browser bundle).
- scripts/build-browser-detector.js and the build:browser script (a stub
  since the JS engine left the tree).
- xtask's detector-archive subcommand and its public-repo lookup.

Came back
- crates/core is now the rule logic itself: every check_* / scan_*, the
  browser adapters, the visual-contrast decisions. It re-exports foundation
  as before, so no consumer changed. Its vectors dispatcher is the union of
  both id tables again, and tests/vectors.rs replays the frozen vectors
  straight through it.
- crates/wasm and crates/xtask join the workspace. cargo xtask bundle builds
  the in-page bundle from browser-bundle/ plus the wasm core, writes
  dist/, refreshes the tracked crates/live/assets/detect-antipatterns-
  browser.js, and writes extension/detector/. bun run build:extension runs
  it instead of downloading.
- crates/live/assets/detect-antipatterns-browser.js is tracked again; live
  mode embeds it and serves it as /detect.js.
- Serde is back to plain derives: no is_human_readable branch in
  js::json_number, derived Serialize for Rgba and BrowserFinding with their
  skip_serializing_if attributes.
- profile.release has lto = "fat" again; rust-toolchain.toml is plain
  stable plus the wasm32 target. The rust, rust-windows and oracle CI jobs
  lose continue-on-error and can be required.

Verified
- cargo build --workspace --all-targets: clean, no warnings.
- cargo test --workspace: 346 pass, 0 fail (the 8 boundary tests are gone
  with the boundary).
- cargo build -p impeccable-wasm --target wasm32-unknown-unknown --release: ok.
- cargo xtask bundle && cargo xtask bundle --check: reproducible; the
  regenerated bundle is committed (it differs from the archived one, which
  was built with a pinned rustc and lto = false).
- cargo build --release -p impeccable: no linker warnings, 12.5 MB (the
  same source at lto = false is 13.1 MB).
- oracle: 795 pass, 0 fail, 0 accepted deltas, 0 missing goldens.
- bun run build, bun run build:extension, web-ext lint (0 errors,
  8 warnings), bun run test: 363 + 80 + 1 + 1 + 133 + 180 + 4 pass, 0 fail.
- impeccable detect --no-config --json tests/fixtures/antipatterns: 128.7 ms
  median of 5.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY
2026-09-03 09:11:33 -07:00

581 lines
20 KiB
YAML

name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
# Nightly full live-e2e matrix. The smoke groups already gate every PR; the
# full sweep is too slow for that, so it runs once a day against main.
schedule:
- cron: '0 7 * * *'
concurrency:
# Scheduled runs get their own group: the 07:00 UTC nightly and a push to
# main share github.ref, and cancel-in-progress would let them kill each
# other mid-run.
group: ${{ github.workflow }}-${{ github.event_name == 'schedule' && 'nightly' || github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
changes:
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 }}
cli_remote_e2e: ${{ steps.plan.outputs.cli_remote_e2e }}
live_e2e: ${{ steps.plan.outputs.live_e2e }}
live_e2e_accept_cleanup: ${{ steps.plan.outputs.live_e2e_accept_cleanup }}
skill_behavior: ${{ steps.plan.outputs.skill_behavior }}
live_svelte_adapter_deepseek: ${{ steps.plan.outputs.live_svelte_adapter_deepseek }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: 24
- name: Detect test plan
id: plan
env:
GITHUB_EVENT_BEFORE: ${{ github.event.before }}
run: node scripts/ci-test-plan.mjs
test-matrix:
name: test (Node ${{ matrix.node-version }})
runs-on: ubuntu-latest
needs: changes
strategy:
fail-fast: false
matrix:
node-version: [22.18.0, 24]
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node-version }}
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Run core tests
run: bun run test:core
- name: Install Puppeteer browser
if: needs.changes.outputs.detector == 'true'
run: bunx puppeteer browsers install chrome
- name: Run detector tests
if: needs.changes.outputs.detector == 'true'
run: bun run test:detector
- name: Run live unit tests
if: needs.changes.outputs.live == 'true'
run: bun run test:live
- name: Run framework fixture tests
if: needs.changes.outputs.framework == 'true'
run: bun run test:framework
- name: Build
run: bun run build
# `bun run build:extension` runs `cargo xtask bundle`: the rule core
# compiled to wasm plus the page JS in browser-bundle/.
- name: Install the pinned toolchain
if: needs.changes.outputs.detector == 'true'
run: rustup show && rustup target add wasm32-unknown-unknown
- uses: Swatinem/rust-cache@v2
if: needs.changes.outputs.detector == 'true'
- name: Install wasm-pack
if: needs.changes.outputs.detector == 'true'
run: cargo install wasm-pack --locked
- name: Build extension
if: needs.changes.outputs.detector == 'true'
run: bun run build:extension
- name: Lint Firefox extension (web-ext)
if: needs.changes.outputs.detector == 'true'
# Pinned for reproducible CI. Fails on AMO errors; innerHTML style
# warnings in the panel renderer are non-blocking and not promoted to
# errors here.
run: npx --yes web-ext@10 lint --source-dir dist/extension-firefox
- name: Verify generated tracked outputs
# extension/detector/ is gitignored (built by `cargo xtask bundle`);
# it stays listed so a stray tracked copy shows up here.
run: git diff --exit-code -- .agents .claude .cursor .gemini .github/skills plugin extension/detector
- name: Upload build artifacts
uses: actions/upload-artifact@v7
with:
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
# The Rust workspace: the engine binary, the rule core, and every crate
# behind them. Everything builds from source with no downloads.
rust:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.rust == 'true'
steps:
- name: Checkout repository
uses: actions/checkout@v7
# rust-toolchain.toml names the channel; `rustup show` installs it.
# Never override the toolchain here.
- name: Install the pinned toolchain
run: rustup show
- uses: Swatinem/rust-cache@v2
- name: Build
run: cargo build --workspace --all-targets
- name: Test
run: cargo test --workspace
# 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'
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.
oracle:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.oracle == 'true' || needs.changes.outputs.rust == 'true'
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: 24
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Install the pinned toolchain
run: rustup show
- uses: Swatinem/rust-cache@v2
- name: Build the engine from source
run: cargo build --release -p impeccable
- name: Replay oracle goldens
env:
IMPECCABLE_BIN: ${{ github.workspace }}/target/release/impeccable
run: node tests/oracle/run.mjs
# Release-order guard (triage decision D4). Verifies that the engine release for
# the pinned ENGINE_VERSION is fully published — the five dist binaries + .sha256
# AND the five @impeccable/cli-<os>-<arch> npm platform packages — before a skill
# release/merge that depends on them. The launcher, npm shim, and
# `impeccable install` all dead-end without those assets.
#
# continue-on-error is a release-time toggle: until the first engine release is
# 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`.
engine-release-ready:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: 24
- name: Check engine release assets for pinned ENGINE_VERSION
id: check
continue-on-error: true
run: node scripts/check-engine-release.mjs
- 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 (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
needs: test-matrix
if: always()
steps:
- name: Verify Node test matrix
run: |
if [ "${{ needs.test-matrix.result }}" != "success" ]; then
echo "test-matrix result: ${{ needs.test-matrix.result }}"
exit 1
fi
echo "test matrix passed"
cli-remote-e2e:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.cli_remote_e2e == 'true'
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: 24
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Run remote CLI E2E smoke
run: bun run test:cli-remote-e2e
live-e2e-smoke:
name: live-e2e smoke (${{ matrix.group }})
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.live_e2e == 'true' && github.event_name != 'workflow_dispatch' && github.event_name != 'schedule'
timeout-minutes: 15
strategy:
fail-fast: true
matrix:
include:
- group: platform
fixtures: astro-vite7,monorepo-nested-vite,nextjs-app-router,vite8-sveltekit
- group: svelte
fixtures: vite8-sveltekit-stateful
- group: react
fixtures: vite8-react-css-modules,vite8-react-insert,vite8-react-plain
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: 24
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Cache fixture npm downloads
uses: actions/cache@v6
with:
path: ~/.npm
key: ${{ runner.os }}-fixture-npm-${{ hashFiles('tests/framework-fixtures/**/files/package.json') }}
restore-keys: |
${{ runner.os }}-fixture-npm-
- name: Cache Playwright Chromium
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('package.json', 'bun.lock') }}
restore-keys: |
${{ runner.os }}-playwright-chromium-
- name: Install dependencies
run: bun install
- name: Install Playwright Chromium
run: npx playwright install chromium
- name: Run live E2E tests
run: bun run test:live-e2e
env:
IMPECCABLE_E2E_ONLY: ${{ matrix.fixtures }}
IMPECCABLE_E2E_SCENARIOS: core
IMPECCABLE_E2E_TEST_TIMEOUT_MS: 180000
IMPECCABLE_E2E_INSTALL_TIMEOUT_MS: 120000
IMPECCABLE_E2E_DEV_READY_TIMEOUT_MS: 60000
IMPECCABLE_E2E_ARTIFACT_DIR: test-results/live-e2e/${{ matrix.group }}
- name: Upload live E2E failure artifacts
if: failure()
uses: actions/upload-artifact@v7
with:
name: live-e2e-smoke-${{ matrix.group }}-artifacts
path: test-results/live-e2e
if-no-files-found: ignore
live-e2e-full:
name: live-e2e full (${{ matrix.group }})
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.live_e2e == 'true' && (github.event_name == 'workflow_dispatch' || github.event_name == 'schedule')
timeout-minutes: 25
strategy:
fail-fast: true
matrix:
include:
- group: platform
fixtures: astro-vite7,monorepo-nested-vite,nextjs-app-router
- group: svelte
fixtures: vite8-sveltekit,vite8-sveltekit-stateful
- group: react-a
fixtures: vite8-https,vite8-react-base-path,vite8-react-csp-meta,vite8-react-css-modules,vite8-react-emotion
- group: react-b
fixtures: vite8-react-insert,vite8-react-mapped-list,vite8-react-modal,vite8-react-plain
- group: stateful
fixtures: vite8-react-radix-dialog,vite8-react-router-spa,vite8-react-styled-components,vite8-react-tabs
- group: styling
fixtures: vite8-react-tailwindv3,vite8-react-tailwindv4,vite8-react-ts,vite8-react-tsx-repeated-aside,vite8-react-unocss,vite8-react-vanilla-extract
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: 24
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Cache fixture npm downloads
uses: actions/cache@v6
with:
path: ~/.npm
key: ${{ runner.os }}-fixture-npm-${{ hashFiles('tests/framework-fixtures/**/files/package.json') }}
restore-keys: |
${{ runner.os }}-fixture-npm-
- name: Cache Playwright Chromium
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('package.json', 'bun.lock') }}
restore-keys: |
${{ runner.os }}-playwright-chromium-
- name: Install dependencies
run: bun install
- name: Install Playwright Chromium
run: npx playwright install chromium
- name: Run live E2E tests
run: bun run test:live-e2e
env:
IMPECCABLE_E2E_ONLY: ${{ matrix.fixtures }}
IMPECCABLE_E2E_TEST_TIMEOUT_MS: 300000
IMPECCABLE_E2E_INSTALL_TIMEOUT_MS: 180000
IMPECCABLE_E2E_DEV_READY_TIMEOUT_MS: 120000
IMPECCABLE_E2E_ARTIFACT_DIR: test-results/live-e2e/${{ matrix.group }}
- name: Upload live E2E failure artifacts
if: failure()
uses: actions/upload-artifact@v7
with:
name: live-e2e-full-${{ matrix.group }}-artifacts
path: test-results/live-e2e
if-no-files-found: ignore
live-e2e-accept-cleanup:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.live_e2e_accept_cleanup == 'true'
timeout-minutes: 15
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
steps:
- name: Skip without provider key
if: ${{ env.ANTHROPIC_API_KEY == '' && env.DEEPSEEK_API_KEY == '' }}
run: echo "Skipping provider-backed accept-cleanup regression because no provider API key is configured."
- name: Checkout repository
if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }}
uses: actions/checkout@v7
- name: Setup Node
if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }}
uses: actions/setup-node@v7
with:
node-version: 24
- name: Setup Bun
if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }}
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Cache fixture npm downloads
if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }}
uses: actions/cache@v6
with:
path: ~/.npm
key: ${{ runner.os }}-fixture-npm-${{ hashFiles('tests/framework-fixtures/**/files/package.json') }}
restore-keys: |
${{ runner.os }}-fixture-npm-
- name: Cache Playwright Chromium
if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }}
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('package.json', 'bun.lock') }}
restore-keys: |
${{ runner.os }}-playwright-chromium-
- name: Install dependencies
if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }}
run: bun install
- name: Install Playwright Chromium
if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }}
run: npx playwright install chromium
- name: Run accept cleanup regression
if: ${{ env.ANTHROPIC_API_KEY != '' || env.DEEPSEEK_API_KEY != '' }}
run: |
if [ -n "$DEEPSEEK_API_KEY" ]; then
export IMPECCABLE_E2E_LLM_PROVIDER=deepseek
else
export IMPECCABLE_E2E_LLM_PROVIDER=anthropic
fi
bun run test:live-e2e-accept-cleanup
live-svelte-adapter-deepseek:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.live_svelte_adapter_deepseek == 'true'
timeout-minutes: 25
env:
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
steps:
- name: Skip without DeepSeek key
if: ${{ env.DEEPSEEK_API_KEY == '' }}
run: echo "Skipping Svelte adapter DeepSeek sweep because DEEPSEEK_API_KEY is not configured."
- name: Checkout repository
if: ${{ env.DEEPSEEK_API_KEY != '' }}
uses: actions/checkout@v7
- name: Setup Node
if: ${{ env.DEEPSEEK_API_KEY != '' }}
uses: actions/setup-node@v7
with:
node-version: 24
- name: Setup Bun
if: ${{ env.DEEPSEEK_API_KEY != '' }}
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Cache fixture npm downloads
if: ${{ env.DEEPSEEK_API_KEY != '' }}
uses: actions/cache@v6
with:
path: ~/.npm
key: ${{ runner.os }}-fixture-npm-${{ hashFiles('tests/framework-fixtures/**/files/package.json') }}
restore-keys: |
${{ runner.os }}-fixture-npm-
- name: Cache Playwright Chromium
if: ${{ env.DEEPSEEK_API_KEY != '' }}
uses: actions/cache@v6
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('package.json', 'bun.lock') }}
restore-keys: |
${{ runner.os }}-playwright-chromium-
- name: Install dependencies
if: ${{ env.DEEPSEEK_API_KEY != '' }}
run: bun install
- name: Install Playwright Chromium
if: ${{ env.DEEPSEEK_API_KEY != '' }}
run: npx playwright install chromium
- name: Run Svelte adapter DeepSeek sweep
if: ${{ env.DEEPSEEK_API_KEY != '' }}
run: bun run test:live-svelte-adapter-deepseek
skill-behavior:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.skill_behavior == 'true' && github.event_name != 'pull_request'
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GOOGLE_CLOUD_API_KEY: ${{ secrets.GOOGLE_CLOUD_API_KEY }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: 24
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install
- name: Run skill behavior tests
run: bun run test:skill-behavior