ci(latency): split the tjs matrix so oracles wait on fast legs only #912
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: ci | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: {} | |
| workflow_dispatch: {} | |
| permissions: | |
| contents: read | |
| # One live run per ref: a newer push (or PR update) cancels the superseded run | |
| # instead of piling four full matrices onto the runners at once. main and each PR | |
| # get their own group so they never cancel each other. | |
| concurrency: | |
| group: ci-${{ github.ref }} | |
| # A push to main NEVER cancels a run in flight. Measured 2026-09-04: 194e237, a | |
| # BACKLOG.md-only commit, cancelled the run carrying 06c6c96 (a real test fix), and the | |
| # phase-2 Windows spawn number went unread for two days because its run was cancelled | |
| # and nobody re-ran it. A slow job behind a long matrix is the MOST likely to be | |
| # cancelled and the LEAST likely to be re-run deliberately, so the jobs with the most to | |
| # say are the ones that systematically never speak. PR branches keep cancelling. | |
| cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} | |
| jobs: | |
| # Derives whether this push touched anything but docs, so the heavy `tjs` | |
| # matrix (and everything that consumes its artifacts) can skip on a | |
| # docs-only push without losing signal on a real one. concurrency.group | |
| # above is evaluated before any job runs and cannot see the diff — this is | |
| # the job that CAN, per scripts/changed-paths.mjs's own header. | |
| # | |
| # MUST FAIL OPEN: `code=true` on any failure to compute the diff, wired | |
| # explicitly (not left to a default), because a classifier that emits | |
| # nothing here would silently skip the whole matrix — worse than the | |
| # cancellation problem this file exists to fix. Two independent layers: | |
| # scripts/changed-paths.mjs itself catches every internal failure and | |
| # still prints `code=true`; the `||` below is a second layer for the case | |
| # the process cannot even start (missing node, a syntax error — the sort | |
| # of failure that fires before any try/catch in that file could run). | |
| changes: | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| outputs: | |
| code: ${{ steps.classify.outputs.code }} | |
| steps: | |
| # Full history: the classifier diffs against the push's "before" SHA | |
| # (or the PR's base SHA), neither of which the default shallow | |
| # (fetch-depth: 1) checkout would have on disk. | |
| - uses: actions/checkout@v7.0.1 | |
| with: | |
| submodules: false | |
| fetch-depth: 0 | |
| - name: Classify the push's changed paths (fails open — code=true on any error) | |
| id: classify | |
| env: | |
| GH_BEFORE_SHA: ${{ github.event.before }} | |
| GH_PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} | |
| GH_PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| run: node scripts/changed-paths.mjs >> "$GITHUB_OUTPUT" || echo "code=true" >> "$GITHUB_OUTPUT" | |
| # tjs legs, CI tier (user decision 2026-07-11): every OS in the release | |
| # matrix on every push, ONE arch each — the manifest (scripts/tjs-legs.mjs) | |
| # owns the list, the reusable workflow owns the job; nothing is duplicated | |
| # against release.yml. VM legs run soft-fail until they earn hard status | |
| # (house rule); the native legs keep their historical hard gate. The tjs | |
| # build restores from the shared cache on native legs, so a typical push | |
| # pays only bundle+fuse+smoke there; VM legs pay the guest boot + in-guest | |
| # build. | |
| # | |
| # SPLIT IN TWO (measured from run 33959009299, 2026-09-05): `needs:` is | |
| # JOB-level, not matrix-leg-level, so the four oracle jobs below (and the | |
| # three Windows jobs) — which between them consume only TWO of this tier's | |
| # 29 artifacts (tjs-linux-x64-musl, tjs-darwin-arm64, tjs-windows-amd64, | |
| # tjs-windows-arm64 — every one a FAST leg) — were blocking on the slowest | |
| # leg in the whole matrix (netbsd-mips64eb, 113 minutes that run) to use | |
| # artifacts that exist at ~9 minutes. This job now runs only:fast (native | |
| # runners, alpine containers, cross-fuse builds — everything | |
| # scripts/tjs-legs.mjs's SLOW_CI_LEG calls fast); `tjs-slow` below runs the | |
| # rest. The split is a leg PROPERTY (guest-platform VM vs. tier2's | |
| # from-source cross-toolchain build), never a name list here, so a leg that | |
| # changes shape re-partitions itself with no YAML edit. Every consumer of | |
| # this job (`needs: tjs`) already only ever downloaded a fast-job artifact, | |
| # so NONE of their `needs:` lines change — keeping this job's name `tjs` | |
| # (rather than renaming it `tjs-fast`) is what makes that true with a | |
| # minimal diff. | |
| tjs: | |
| needs: changes | |
| # THE heavy matrix this whole `changes` job exists to gate: a full OS | |
| # sweep. Skips outright on a docs-only push (task-7); on a real code | |
| # push it is unaffected. See `changes`'s own header for why this must | |
| # fail open rather than fail silent. | |
| # | |
| # DO NOT "simplify" this to `needs.changes.outputs.code == 'true'` — that | |
| # reads more naturally but is the exact bug fix round 1 caught (Finding | |
| # 1, CRITICAL): an `if:` with no status-check function is implicitly | |
| # ANDed with success(), so if the `changes` JOB itself dies before its | |
| # classify step runs (checkout timeout, a dead runner, any infra | |
| # hiccup) — as opposed to the classify STEP failing, which the script's | |
| # own fail-open logic already covers — `outputs.code` is never set, | |
| # `success()` is false, and `== 'true'` is false too: the ENTIRE heavy | |
| # matrix vanishes, reported as skipped, not failed. That is verbatim | |
| # "strictly worse than the problem being fixed." `!cancelled()` supplies | |
| # an explicit status-check function (suppressing the implicit | |
| # success()), and `!= 'false'` means unknown/empty/errored all default | |
| # to RUNNING — the same default-deny posture the classifier itself | |
| # takes — while a genuine `code=false` (docs-only, computed | |
| # successfully) is still the literal string 'false' and still skips. | |
| if: needs.changes.outputs.code != 'false' && !cancelled() | |
| uses: ./.github/workflows/tjs-legs.yml | |
| permissions: | |
| contents: read | |
| with: | |
| tier: ci | |
| only: fast | |
| # The slow half of the split above: VM/qemu guests + the from-source NetBSD | |
| # cross-toolchain fleet (scripts/tjs-legs.mjs's SLOW_CI_LEG) — everything | |
| # the fast `tjs` job above does not build. Nothing in this workflow | |
| # `needs:` this job; it exists purely so those legs still run on every push | |
| # (a re-partition, not a reduction — same 29 legs across the two jobs as | |
| # ran in the single `tjs` job before the split). Same `changes` gate as | |
| # `tjs`, for the same reason: it must skip in lockstep on a docs-only push | |
| # rather than spin up guest VMs for nothing. `!= 'false'` not `== 'true'` | |
| # — see `tjs`'s own comment above (fix round 1, Finding 1: a dead | |
| # `changes` job must not silently skip this too). | |
| tjs-slow: | |
| needs: changes | |
| if: needs.changes.outputs.code != 'false' && !cancelled() | |
| uses: ./.github/workflows/tjs-legs.yml | |
| permissions: | |
| contents: read | |
| with: | |
| tier: ci | |
| only: slow | |
| # Windows shim + sync-primitive coverage (windows-tjs-port spec). Windows is | |
| # the ONE platform whose shim + sync-primitive layer diverges from POSIX | |
| # (CRLF, cmd.exe/PATHEXT, drive paths, O_BINARY, the Win32 C sync primitives | |
| # mod_fs_sync/mod_spawn_sync) — POSIX legs get this coverage free from the | |
| # host node:test suite on the dev box, but it can only be exercised on a | |
| # real Windows kernel against a real tjs.exe. Consumes the windows-amd64 leg's | |
| # tjs-windows-amd64 engine; the leg's own exec=host job does build+fuse+PONG+ | |
| # publish (subsuming the old fuse-build/fuse-run/builder-smoke jobs). Because | |
| # this installs the pinned @anthropic-ai/claude-code (UPSTREAM_PIN) it is also | |
| # the Windows bundle-bump gate. These finer signals isolate a C-primitive regression from | |
| # a shim/Bash-tool regression — what the leg's end-to-end PONG cannot. | |
| windows-amd64-tests: | |
| needs: [changes, tjs] | |
| # Run once `tjs` FINISHES, whatever its conclusion — do NOT skip because some | |
| # OTHER leg failed. This job consumes ONE named artifact; a leg it does not use | |
| # has no bearing on whether it can run. Plain `needs: tjs` skips the moment any | |
| # HARD leg fails, which silently deletes this signal: when haiku-x64 was promoted | |
| # to gating (2026-07-17), a single real haiku regression skipped this job and the | |
| # three windows jobs in one stroke — a broken gate masking working gates, the | |
| # exact disease that let the dep-closure P0 through (node-shim-oracle skipped at | |
| # 9e968b4 for the same reason). If the artifact this job needs is genuinely | |
| # missing, download-artifact fails it LOUDLY, which is the honest answer. | |
| # | |
| # ALSO gated on `changes`: this job downloads a `tjs` artifact, so it must | |
| # skip in lockstep with `tjs` on a docs-only push (task-7) rather than | |
| # run and fail loudly on a missing artifact. `!= 'false'` not `== 'true'` | |
| # — see `tjs`'s own comment (fix round 1, Finding 1: a dead `changes` job | |
| # must not silently skip this too). | |
| if: needs.changes.outputs.code != 'false' && !cancelled() | |
| runs-on: windows-latest | |
| defaults: | |
| run: | |
| shell: bash | |
| steps: | |
| - uses: actions/checkout@v7.0.1 | |
| with: | |
| submodules: false | |
| - uses: actions/setup-node@v7.0.0 | |
| with: | |
| node-version-file: .tool-versions | |
| - uses: actions/download-artifact@v8.0.1 | |
| with: | |
| name: tjs-windows-amd64 | |
| path: . | |
| - name: Install the Claude provider (npm, pinned — the bundle-bump gate) | |
| run: | | |
| V=$(sed -n 's/^claude-code //p' UPSTREAM_PIN) | |
| echo "provider pin: @anthropic-ai/claude-code@$V (deliberately behind upstream — see UPSTREAM_PIN)" | |
| npm i -g @anthropic-ai/claude-code@"$V" 2>&1 | tail -3 | |
| - name: Install runtime deps (semver et al.) for the oracles | |
| run: npm ci --prefix deps/claude | |
| - name: Sync-primitive battery on the real Windows kernel | |
| run: | | |
| set -euo pipefail | |
| chmod +x ./tjs.exe || true | |
| ./tjs.exe run test/win-sync-battery.js | tee battery.out | |
| grep -q '^BATTERY OK' battery.out | |
| - name: node-shim oracles (roundtrip + agentic) on the real Windows kernel | |
| run: | | |
| set -euo pipefail | |
| PROV="$(node scripts/stage-provider.mjs)" || exit 1 | |
| echo "provider: $PROV" | |
| CLODE_TJS="$PWD/tjs.exe" CLODE_PROVIDER_BIN="$PROV" \ | |
| node --test --test-concurrency=1 \ | |
| test/node-shim-roundtrip.test.cjs \ | |
| test/node-shim-agentic.test.cjs | |
| # Windows ConPTY TUI render (windows-tjs-port spec, sub-project D). The FIRST | |
| # CI PTY harness on any platform: build a real quaude (`clode build`) and drive | |
| # IT directly (tjs.exe + node-shim + the real Claude Code bundle → Ink; no | |
| # launcher involved — a fused quaude carries its own engine and deps as | |
| # members) inside a real ConPTY (node-pty) with an xterm emulator on the far | |
| # end, and assert the welcome box painted. Runs the SAME | |
| # test/e2e-tui-tjs.test.cjs as POSIX, forcing CLODE_LIVE_RENDER=1 here — a | |
| # no-op now that the test's own gate (test/live-render-helper.cjs) already | |
| # defaults to "run" off darwin. There is no Keychain GUI modal on Windows to | |
| # hang on. CORRECTED: this comment used to claim that was also "the reason | |
| # POSIX CI can't run it" — false; POSIX here means Linux, which likewise has | |
| # no Keychain (there is no `security` binary on PATH at all), so Linux CI can | |
| # run this file too (see .superpowers/sdd/2026-09-02-phase2-name-the-steps/ | |
| # linux-pty-experiment.md) — it simply never had a tjs binary available to | |
| # do so. Consumes the windows-amd64 leg's tjs-windows-amd64 engine. | |
| # Because it installs the latest provider it is also a Windows render bump gate. | |
| # Render-only; the ConPTY INPUT round-trip is the tracked follow-on (D2). | |
| windows-amd64-tui: | |
| needs: [changes, tjs] | |
| # Run once `tjs` FINISHES, whatever its conclusion — do NOT skip because some | |
| # OTHER leg failed. This job consumes ONE named artifact; a leg it does not use | |
| # has no bearing on whether it can run. Plain `needs: tjs` skips the moment any | |
| # HARD leg fails, which silently deletes this signal: when haiku-x64 was promoted | |
| # to gating (2026-07-17), a single real haiku regression skipped this job and the | |
| # three windows jobs in one stroke — a broken gate masking working gates, the | |
| # exact disease that let the dep-closure P0 through (node-shim-oracle skipped at | |
| # 9e968b4 for the same reason). If the artifact this job needs is genuinely | |
| # missing, download-artifact fails it LOUDLY, which is the honest answer. | |
| # | |
| # ALSO gated on `changes`: this job downloads a `tjs` artifact, so it must | |
| # skip in lockstep with `tjs` on a docs-only push (task-7) rather than | |
| # run and fail loudly on a missing artifact. `!= 'false'` not `== 'true'` | |
| # — see `tjs`'s own comment (fix round 1, Finding 1: a dead `changes` job | |
| # must not silently skip this too). | |
| if: needs.changes.outputs.code != 'false' && !cancelled() | |
| runs-on: windows-latest | |
| defaults: | |
| run: | |
| shell: bash | |
| steps: | |
| - uses: actions/checkout@v7.0.1 | |
| with: | |
| submodules: false | |
| - uses: actions/setup-node@v7.0.0 | |
| with: | |
| node-version-file: .tool-versions | |
| - uses: actions/download-artifact@v8.0.1 | |
| with: | |
| name: tjs-windows-amd64 | |
| path: . | |
| - name: Install the Claude provider (npm, pinned — the render bump gate) | |
| run: | | |
| V=$(sed -n 's/^claude-code //p' UPSTREAM_PIN) | |
| echo "provider pin: @anthropic-ai/claude-code@$V (deliberately behind upstream — see UPSTREAM_PIN)" | |
| npm i -g @anthropic-ai/claude-code@"$V" 2>&1 | tail -3 | |
| - name: Install root runtime deps (render seed deps) | |
| run: npm ci --prefix deps/claude | |
| - name: Install the PTY harness (node-pty win32-x64 prebuilt + @xterm/headless) | |
| run: npm install --prefix test | |
| - name: Preflight — node-pty can open a ConPTY on this kernel | |
| run: node test/harness-preflight.cjs | |
| - name: Render the real TUI under ConPTY and assert the welcome box | |
| run: | | |
| set -euo pipefail | |
| PROV="$(node scripts/stage-provider.mjs)" || exit 1 | |
| echo "provider: $PROV" | |
| chmod +x ./tjs.exe || true | |
| CLODE_TJS="$PWD/tjs.exe" CLODE_CLAUDE_BIN="$PROV" CLODE_LIVE_RENDER=1 \ | |
| node --test --test-concurrency=1 test/e2e-tui-tjs.test.cjs | |
| - name: Characterize the node-shim TTY input path under ConPTY (D2) | |
| run: | | |
| set -euo pipefail | |
| chmod +x ./tjs.exe || true | |
| # Hermetic tjs-vs-node stdin/TTY differential (raw-keystroke ordering, | |
| # paused-mode read() as Ink reads, setRawMode, UTF-8 split-write) under | |
| # a real ConPTY. No provider/network — CLODE_TJS + the node-pty harness | |
| # (already installed above) are all it needs. First CI TTY-input coverage | |
| # on any platform; a failure pinpoints a shim divergence from node. | |
| CLODE_TJS="$PWD/tjs.exe" \ | |
| node --test --test-concurrency=1 test/node-shim-tty.test.cjs | |
| # Windows shim + sync-primitive coverage on the ARM64 kernel — the same | |
| # rationale as windows-amd64-tests, exercised against the windows-arm64 leg's | |
| # native MSVC ARM64 engine. No tui counterpart yet (out of scope). | |
| windows-arm64-tests: | |
| needs: [changes, tjs] | |
| # Run once `tjs` FINISHES, whatever its conclusion — do NOT skip because some | |
| # OTHER leg failed. This job consumes ONE named artifact; a leg it does not use | |
| # has no bearing on whether it can run. Plain `needs: tjs` skips the moment any | |
| # HARD leg fails, which silently deletes this signal: when haiku-x64 was promoted | |
| # to gating (2026-07-17), a single real haiku regression skipped this job and the | |
| # three windows jobs in one stroke — a broken gate masking working gates, the | |
| # exact disease that let the dep-closure P0 through (node-shim-oracle skipped at | |
| # 9e968b4 for the same reason). If the artifact this job needs is genuinely | |
| # missing, download-artifact fails it LOUDLY, which is the honest answer. | |
| # | |
| # ALSO gated on `changes`: this job downloads a `tjs` artifact, so it must | |
| # skip in lockstep with `tjs` on a docs-only push (task-7) rather than | |
| # run and fail loudly on a missing artifact. `!= 'false'` not `== 'true'` | |
| # — see `tjs`'s own comment (fix round 1, Finding 1: a dead `changes` job | |
| # must not silently skip this too). | |
| if: needs.changes.outputs.code != 'false' && !cancelled() | |
| runs-on: windows-11-arm | |
| defaults: | |
| run: | |
| shell: bash | |
| steps: | |
| - uses: actions/checkout@v7.0.1 | |
| with: | |
| submodules: false | |
| - uses: actions/setup-node@v7.0.0 | |
| with: | |
| node-version-file: .tool-versions | |
| - uses: actions/download-artifact@v8.0.1 | |
| with: | |
| name: tjs-windows-arm64 | |
| path: . | |
| - name: Install the Claude provider (npm, pinned — the bundle-bump gate) | |
| run: | | |
| V=$(sed -n 's/^claude-code //p' UPSTREAM_PIN) | |
| echo "provider pin: @anthropic-ai/claude-code@$V (deliberately behind upstream — see UPSTREAM_PIN)" | |
| npm i -g @anthropic-ai/claude-code@"$V" 2>&1 | tail -3 | |
| - name: Install runtime deps (semver et al.) for the oracles | |
| run: npm ci --prefix deps/claude | |
| - name: Sync-primitive battery on the real Windows kernel | |
| run: | | |
| set -euo pipefail | |
| chmod +x ./tjs.exe || true | |
| ./tjs.exe run test/win-sync-battery.js | tee battery.out | |
| grep -q '^BATTERY OK' battery.out | |
| - name: node-shim oracles (roundtrip + agentic) on the real Windows kernel | |
| run: | | |
| set -euo pipefail | |
| PROV="$(node scripts/stage-provider.mjs)" || exit 1 | |
| echo "provider: $PROV" | |
| CLODE_TJS="$PWD/tjs.exe" CLODE_PROVIDER_BIN="$PROV" \ | |
| node --test --test-concurrency=1 \ | |
| test/node-shim-roundtrip.test.cjs \ | |
| test/node-shim-agentic.test.cjs | |
| # templates-drift MOVED OUT OF ci.yml (2026-08-24). | |
| # | |
| # It compared the published pack's engine recipe against this tree's, on every | |
| # push — so it went red the moment engine sources moved past the last release, | |
| # which is the NORMAL state of a repo doing engine work. That is a STATE, not a | |
| # fault, and "cut a release" was never a fix: it reset the baseline until the | |
| # next engine commit. The cost was real — main was permanently red, so "is main | |
| # green?" stopped being a usable question, which is precisely how an ambient red | |
| # hid a P0 across 13 jobs before (see BACKLOG). | |
| # | |
| # Worse, the hazard it warned about was defended NOWHERE. libexec/clode-templates | |
| # .cjs checked only the coarse tjsPin (txiki version + short sha), so two clodes | |
| # with the same pin and different patch stacks both accepted the same pack. | |
| # | |
| # The defence now lives where a wrong answer costs someone something: obtainEngine | |
| # compares the manifest's recipe (stamped by 4f86738) against the recipe baked | |
| # into the running clode (__CLODE_BAKED_ENGINE_RECIPE__) and REFUSES a mismatched | |
| # pack at fetch. Missing on either side is reported as "cannot check", never | |
| # treated as a match. See test/clode-templates.test.cjs. | |
| # | |
| # And the drift check itself moved to release.yml, where "we are about to publish | |
| # templates that do not match this tree" IS a fault. | |
| test: | |
| uses: ./.github/workflows/suite.yml | |
| permissions: | |
| contents: read | |
| # node-shim oracle — the node-vs-tjs behavioral diff, run under a REAL tjs. | |
| # WHY THIS JOB EXISTS: the `test` job above (npm test) never builds a tjs, so | |
| # every `skipUnlessTjs` node-shim oracle test SILENTLY SKIPS there — the diff | |
| # that catches shim-vs-node divergence was disabled in the default path. That | |
| # gap let a real bug ship: fs.readFileSync returned a bare Uint8Array (not a | |
| # Buffer), so .toString('hex')/.readUInt8 were silently wrong and the Edit tool | |
| # failed under tjs — no wall, no crash, green everywhere (fixed 2026-07-15, | |
| # A1 audit). A dedicated, visible job is the standing guard: this class cannot | |
| # hide again. Reuses the CI-tier linux-x64-musl leg's bare-engine artifact | |
| # (tjs-<leg>, uploaded expressly "for oracle jobs") — no rebuild. Same run | |
| # pattern release.yml's be-oracle proved green on s390x-musl. | |
| node-shim-oracle: | |
| needs: [changes, tjs] | |
| # Run once `tjs` FINISHES, whatever its conclusion — do NOT skip because some | |
| # OTHER leg failed. This job consumes ONE named artifact; a leg it does not use | |
| # has no bearing on whether it can run. Plain `needs: tjs` skips the moment any | |
| # HARD leg fails, which silently deletes this signal: when haiku-x64 was promoted | |
| # to gating (2026-07-17), a single real haiku regression skipped this job and the | |
| # three windows jobs in one stroke — a broken gate masking working gates, the | |
| # exact disease that let the dep-closure P0 through (node-shim-oracle skipped at | |
| # 9e968b4 for the same reason). If the artifact this job needs is genuinely | |
| # missing, download-artifact fails it LOUDLY, which is the honest answer. | |
| # | |
| # ALSO gated on `changes`: this job downloads a `tjs` artifact, so it must | |
| # skip in lockstep with `tjs` on a docs-only push (task-7) rather than | |
| # run and fail loudly on a missing artifact. `!= 'false'` not `== 'true'` | |
| # — see `tjs`'s own comment (fix round 1, Finding 1: a dead `changes` job | |
| # must not silently skip this too). | |
| if: needs.changes.outputs.code != 'false' && !cancelled() | |
| runs-on: ubuntu-latest | |
| # LIKE-FOR-LIKE LIBC, ON PURPOSE — the reference node runs under musl because | |
| # the engine does. | |
| # | |
| # This job diffs a MUSL-built engine against "host node". On a stock | |
| # ubuntu-latest, host node is a GLIBC build, so the premise "our engine vs the | |
| # node on this host" is false and the ratchet was really measuring libc. It | |
| # caught itself doing that on 2026-08-23 (run 32606247462), three rows red with | |
| # BOTH SIDES CORRECT: | |
| # os.constants.signals ours had SIGUNUSED: 31, node's did not | |
| # os.constants.dlopen ours 4 members, node's 5 (RTLD_DEEPBIND) | |
| # musl's arch/*/bits/signal.h literally says `#define SIGUNUSED SIGSYS`, and | |
| # musl's dlfcn.h has no RTLD_DEEPBIND; glibc 2.39 says the opposite on both | |
| # (verified directly: `echo '#include <signal.h>' | gcc -E -dM -` on Ubuntu | |
| # 24.04 prints SIGSYS 31 and no SIGUNUSED). Since f8546da/0decc98 the engine | |
| # reports what ITS OWN headers say — that is the entire point of the generated | |
| # constants work, it is what abolished the hand-maintained platform tables — so | |
| # a glibc reference makes an exact gate fail for something that is not a defect. | |
| # | |
| # The fix is the INSTRUMENT, not the ratchet. There is deliberately NO exception | |
| # list, NO skip list, NO "allowed libc differences" file: every entry in such a | |
| # file is a hole in a gate whose whole value is being exact, and a curated table | |
| # of blessed divergences is the hand-maintained platform table walking back in | |
| # through the side door. Make the comparison honest instead. | |
| # | |
| # WHY A CONTAINER AND NOT setup-node: actions/setup-node serves the official | |
| # nodejs.org builds, which are glibc-only — there is no musl node it can give | |
| # us. The image IS the reference node. Its tag is pinned to the SAME version as | |
| # .tool-versions and asserted below, so a bump to one and not the other is a | |
| # named failure rather than a silent skew. | |
| container: | |
| image: node:24.20.0-alpine | |
| defaults: | |
| run: | |
| shell: bash | |
| steps: | |
| # BEFORE checkout on purpose: a `run:` step in a container job needs no repo, | |
| # and actions/checkout with no git on PATH silently degrades to a REST tarball | |
| # download. Each package is load-bearing; alpine ships none of them: | |
| # bash `defaults.run.shell: bash`, and the steps below are bash | |
| # git so checkout is a real clone, not the degraded fallback | |
| # openssl two rows (node-shim-http-client, node-shim-http-proxy) MINT A | |
| # CERTIFICATE and t.skip() without it. A skipped oracle row is | |
| # not a pass — that silent-green is the disease this whole job | |
| # exists to prevent, so the container must not reintroduce it. | |
| # procps node-shim-signals reads `ps -o state= -p PID` to prove SIGSTOP | |
| # actually stopped the child. busybox's ps has no `state` | |
| # column, so the probe would return 'GONE' and the row would | |
| # fail for the tool, not the shim. | |
| # libstdc++ the Bun-packaged provider binary links it. node:alpine | |
| # already pulls it in for node itself; named explicitly so the | |
| # dependency is stated rather than inherited by luck. | |
| # The other externals these tests reach for — /bin/echo, /bin/cat, /bin/sh, | |
| # /bin/sleep, /bin/pwd, mkfifo, kill, uname, true — are busybox applets that | |
| # alpine already symlinks into place, and every row that uses one runs the | |
| # SAME binary on both sides of the diff, so busybox-vs-coreutils cannot skew | |
| # a comparison here. | |
| - name: musl prerequisites (bash, git, openssl, procps — see above) | |
| shell: sh | |
| run: | | |
| set -e | |
| # `procps` is the provides-name; the real package is procps-ng on | |
| # alpine >= 3.19. Asking for the provides-name works on both sides of | |
| # that rename. | |
| apk add --no-cache bash git openssl procps libstdc++ | |
| # Name the tool failure HERE rather than letting it surface as a red | |
| # SIGSTOP row two steps later. procps-ng installs /usr/bin/ps, which | |
| # precedes busybox's /bin/ps on alpine's PATH. | |
| ps -o state= -p $$ >/dev/null \ | |
| || { echo "ps has no 'state' column — node-shim-signals' SIGSTOP probe cannot work" >&2; exit 1; } | |
| - uses: actions/checkout@v7.0.1 | |
| # The same .tool-versions skew guard suite.yml runs, PLUS the assertion that | |
| # makes this job's premise checkable instead of assumed. If someone ever drops | |
| # the container: key, or the image starts shipping a glibc node, this fails by | |
| # name here rather than reappearing as a mystery constants diff. | |
| - name: Assert the reference node matches the .tool-versions pin AND is musl | |
| run: | | |
| set -euo pipefail | |
| want=$(sed -n 's/^nodejs //p' .tool-versions) | |
| have=$(node -v); have=${have#v} | |
| [ "$have" = "$want" ] || { | |
| echo "node skew: .tool-versions=$want but this container's node=$have." >&2 | |
| echo " Bump the container: image: tag in this job to node:$want-alpine." >&2 | |
| exit 1; } | |
| # process.report is node's own answer about its own linkage: a glibc build | |
| # reports header.glibcVersionRuntime, a musl build has no such field. That | |
| # is arch-independent and does not depend on ldd's output format. | |
| node -e ' | |
| const h = process.report.getReport().header; | |
| if (h.glibcVersionRuntime) { | |
| console.error("reference node is GLIBC-linked (" + h.glibcVersionRuntime + "),"); | |
| console.error("but this job diffs a musl engine against it. The comparison would"); | |
| console.error("be measuring libc, not shim fidelity. Restore the alpine container."); | |
| process.exit(1); | |
| } | |
| console.log("reference node: " + process.version + ", musl-linked (no glibcVersionRuntime)"); | |
| ' | |
| - uses: actions/download-artifact@v8.0.1 | |
| with: | |
| name: tjs-linux-x64-musl # bare static engine from the CI-tier leg | |
| path: ${{ runner.temp }}/tjs | |
| - name: Install the runtime dep closure (ws/yaml/semver/string-width/... for the shim) | |
| run: npm ci --prefix deps/claude | |
| # The naude-vs-quaude parity gate stages cli.cjs from a real Bun-packaged | |
| # provider; with none present it SKIPS, which is exactly the silent-green | |
| # this job exists to prevent. Same provisioning the windows-amd64 job uses. | |
| # Under the musl container npm resolves the optional dep | |
| # @anthropic-ai/claude-code-linux-x64-musl (upstream publishes it beside the | |
| # glibc one), so the provider is musl too — the same like-for-like the | |
| # reference node is here for. | |
| - name: Install the Claude provider (npm, pinned — the parity gate's input) | |
| run: | | |
| V=$(sed -n 's/^claude-code //p' UPSTREAM_PIN) | |
| echo "provider pin: @anthropic-ai/claude-code@$V (deliberately behind upstream — see UPSTREAM_PIN)" | |
| npm i -g @anthropic-ai/claude-code@"$V" 2>&1 | tail -3 | |
| # Live-network gate (regression guard): nothing else in CI makes a real | |
| # outbound connection — every API turn is opt-in and skips — which is how an | |
| # IPv6-first fetch-connect regression once shipped (f8f6e1d: connected to only | |
| # the first resolved address, so a v4-only host died at wsi=NULL). A | |
| # credential-free HEAD proves the built engine can DNS + TLS-connect + get a | |
| # response. Retries so a transient network blip doesn't flake the gate. | |
| - name: Live-connect smoke — the engine reaches the internet over TLS | |
| env: | |
| CLODE_TJS: ${{ runner.temp }}/tjs/tjs | |
| run: | | |
| set -uo pipefail | |
| chmod +x "$CLODE_TJS" | |
| # Raw `tjs eval` has no node-shim `process`, so signal via a marker the | |
| # shell checks (a rejected fetch -> no OK marker -> the gate fails). | |
| P='fetch("https://example.com/",{method:"HEAD"}).then(function(r){console.log("LIVE-CONNECT-OK "+r.status)}).catch(function(e){console.log("LIVE-CONNECT-FAIL "+String(e))})' | |
| for i in 1 2 3; do | |
| out=$("$CLODE_TJS" eval "$P" 2>&1 || true); echo "$out" | |
| case "$out" in *LIVE-CONNECT-OK*) exit 0 ;; esac | |
| echo "retry $i…"; sleep 3 | |
| done | |
| echo "live-connect smoke: the engine could not establish a TLS connection" >&2; exit 1 | |
| - name: node-shim oracle suite (every module diffed vs host node, under tjs) | |
| env: | |
| CLODE_TJS: ${{ runner.temp }}/tjs/tjs | |
| run: | | |
| set -euo pipefail | |
| chmod +x "$CLODE_TJS" | |
| "$CLODE_TJS" eval 'console.log("linux-x64-musl tjs alive:", tjs.version)' | |
| # EXCLUDED — the PTY tests, which need a controlling terminal + the | |
| # node-pty harness dep this bare-engine runner has neither of: | |
| # node-shim-tty no controlling terminal on a headless runner | |
| # (Ctrl-Z is no longer a node-shim test: node-shim-ctrlz-pty was replaced by | |
| # test/e2e-ctrlz-tui.test.cjs — a real quaude TUI survives-Ctrl-Z regression | |
| # guard, opt-in via CLODE_LIVE_RENDER; the SIGTSTP/SIGCONT wiring stays in | |
| # node-shim-signals. The new node-shim-api-surface-gate IS picked up here — | |
| # it hard-gates upstream Bun.*/require drift against the latest provider.) | |
| # node-shim-core and node-shim-bunshim are NO LONGER excluded: the two | |
| # Linux-portability bugs they caught are FIXED and now gated here — | |
| # node-shim-core os.constants.signals is OS-derived per platform | |
| # (SIGBUS 7 etc. on Linux; os.cjs SIGNALS_LINUX + | |
| # __tjs_signals), no longer a hardcoded darwin table. | |
| # node-shim-bunshim bun:ffi suffix is platform-aware ('so' on Linux). | |
| # node-shim-child-process was un-excluded earlier: its `spawnSync: cwd` row | |
| # failed because musl rejected posix_spawn cwd (the addchdir_np gate was | |
| # #ifdef __GLIBC__), so tar's KAT and `clode fetch --naude` died "no tar | |
| # tool found on PATH". Fixed in txiki-sync-spawn.patch (parent-side chdir | |
| # dance on musl); this file runs on musl as the standing guard. | |
| EXCLUDE='node-shim-tty' | |
| FILES=$(ls test/node-shim-*.test.cjs | grep -vE "$EXCLUDE") | |
| # The parity gate's input: cli.cjs is staged from this provider and run | |
| # under BOTH targets' runtimes (naude = node, quaude = tjs+shim), then | |
| # diffed. Fail loud if it is missing — a skipped oracle is not a pass. | |
| PROV="$(node scripts/stage-provider.mjs)" || exit 1 | |
| echo "provider: $PROV" | |
| export CLODE_PROVIDER_BIN="$PROV" | |
| # test/shim-surface.test.cjs (task-8, phase 5) needs exactly this job's | |
| # two inputs — CLODE_TJS above, CLODE_PROVIDER_BIN just set — and nothing | |
| # else. It had been skipping in `test` (suite.yml, npm test) since | |
| # upstream 2.1.243, silently: that job has neither input, and the file's | |
| # own header warns a skipped run there "would contribute zero gaps and | |
| # read exactly like a clean one." This job already carries both. | |
| FILES="$FILES test/shim-surface.test.cjs" | |
| # test/guard-subcommands-gate.test.cjs (task-8, phase 5) reads the REAL | |
| # ~/.cache/clode/<version>/ carve — deliberately os.homedir(), ignoring | |
| # CLODE_CACHE/CLODE_STATE_ROOT (see the file's own header) — so it was | |
| # ALSO skipping in `test` (an empty cache there), and this job's EXISTING | |
| # provider staging cannot satisfy it either: stage-provider.mjs's | |
| # minimised copy lives in a temp dir with no /versions/ or /providers/ | |
| # marker in its path, so clode-resolve.cjs's cacheKey() falls back to a | |
| # content hash instead of the pin, and nothing here has ever populated a | |
| # PIN-VERSIONED cache entry. Seed one directly, with the SAME | |
| # extractIfNeeded() that `clode build` itself calls | |
| # (libexec/clode-fuse.cjs's stageUpstreamCli) — without paying for a | |
| # whole quaude fuse just to get one cache entry on disk. | |
| PIN=$(sed -n 's/^claude-code //p' UPSTREAM_PIN) | |
| node -e ' | |
| const path = require("node:path"); | |
| const { extractIfNeeded } = require("./libexec/clode-extract.cjs"); | |
| const { clodeCacheDir } = require("./libexec/clode-paths.cjs"); | |
| const pin = process.argv[1]; | |
| const cacheDir = path.join(clodeCacheDir(process.env), pin); | |
| extractIfNeeded({ bin: process.env.CLODE_PROVIDER_BIN, cacheDir, libexec: "libexec", key: pin }); | |
| console.log("seeded real cache:", cacheDir); | |
| ' "$PIN" | |
| FILES="$FILES test/guard-subcommands-gate.test.cjs" | |
| node --test --test-concurrency=1 $FILES 2>&1 | tee shim-oracle.out | |
| # A SKIPPED run of either newly-added file must be distinguishable from a | |
| # clean one — never a silent pass (phase-5 doctrine: a red that cannot | |
| # tell "broken" from "not given what I need" carries no information, and | |
| # so does a green). Both files' own t.skip() already names a reason when | |
| # their precondition is absent; this asserts the job as a whole REFUSES | |
| # to stay green if either one goes back to skipping — e.g. if the seed | |
| # step above silently landed nowhere, or a future change drops CLODE_TJS | |
| # here. `2` is this job's PRE-EXISTING, environment-appropriate skip | |
| # count (a darwin-only oracle row, and the CLODE_LIVE_ROUNDTRIP opt-in | |
| # finale) — neither newly-added file may add to it. | |
| grep -qE '^(ℹ|#) skipped 2$' shim-oracle.out \ | |
| || { echo "ERROR: expected exactly 2 skips (pre-existing darwin-only + live-roundtrip-opt-in rows). A different count means shim-surface or guard-subcommands-gate skipped again (task-8 regressed), or an unrelated new skip appeared — see above." >&2; grep -E 'skipped' shim-oracle.out >&2; exit 1; } | |
| # The BUILDER and the PACKAGED TARGETS — on glibc, on purpose. | |
| # | |
| # Two standing guards that were the tail of node-shim-oracle until that job had | |
| # to move into a musl container to make its comparison honest (see its header). | |
| # They could not follow it: both build and RUN a naude, and a naude embeds the | |
| # pinned official Node from nodejs.org — a glibc binary that does not exec on | |
| # alpine. Splitting them off costs a second artifact download, a second | |
| # deps/claude install and a second provider install; the alternative was one job | |
| # whose libc had to be wrong for one half of it or the other. | |
| # | |
| # Consumes the SAME tjs-linux-x64-musl artifact — the engine is static, so it | |
| # runs on either libc; it is only the REFERENCE side of a comparison that has to | |
| # match, and nothing here compares against host node. | |
| native-builder-oracle: | |
| needs: [changes, tjs] | |
| # Same rationale as node-shim-oracle's: run once `tjs` FINISHES whatever its | |
| # conclusion. This job consumes ONE named artifact; a leg it does not use has | |
| # no bearing on whether it can run, and a job that skips is a gate that is not | |
| # there. If the artifact is genuinely missing, download-artifact fails LOUDLY. | |
| # | |
| # ALSO gated on `changes`: this job downloads a `tjs` artifact, so it must | |
| # skip in lockstep with `tjs` on a docs-only push (task-7) rather than | |
| # run and fail loudly on a missing artifact. `!= 'false'` not `== 'true'` | |
| # — see `tjs`'s own comment (fix round 1, Finding 1: a dead `changes` job | |
| # must not silently skip this too). | |
| if: needs.changes.outputs.code != 'false' && !cancelled() | |
| runs-on: ubuntu-latest | |
| defaults: | |
| run: | |
| shell: bash | |
| steps: | |
| - uses: actions/checkout@v7.0.1 | |
| - uses: actions/setup-node@v7.0.0 | |
| with: | |
| node-version-file: .tool-versions | |
| - uses: actions/download-artifact@v8.0.1 | |
| with: | |
| name: tjs-linux-x64-musl # bare static engine from the CI-tier leg | |
| path: ${{ runner.temp }}/tjs | |
| - name: Install the runtime dep closure (the fuse's ext-dep closure) | |
| run: npm ci --prefix deps/claude | |
| # Fail loud if absent, below — a skipped acceptance is not a pass. | |
| - name: Install the Claude provider (npm, pinned — the acceptances' input) | |
| run: | | |
| V=$(sed -n 's/^claude-code //p' UPSTREAM_PIN) | |
| echo "provider pin: @anthropic-ai/claude-code@$V (deliberately behind upstream — see UPSTREAM_PIN)" | |
| npm i -g @anthropic-ai/claude-code@"$V" 2>&1 | tail -3 | |
| - name: mark the engine executable | |
| run: chmod +x "${{ runner.temp }}/tjs/tjs" | |
| # The BUILDER, driven the way a user drives it: fuse a native clode, then | |
| # make THAT binary build a quaude with node absent from PATH. | |
| # | |
| # WHY THIS STEP EXISTS (same disease as this job's own header, caught again | |
| # 2026-07-16): clode-native's acceptances 2+3 are the only proof that the | |
| # node-free builder can build the product, and they skip silently without | |
| # CLODE_PROVIDER_BIN. No workflow named the file, so they ran ONLY under | |
| # `npm test` — a job with no tjs and no provider, where they always skipped. | |
| # A commit then made the dep-closure gate spawn `process.execPath -e` to | |
| # introspect the bun-shim; under a fused builder process.execPath IS the | |
| # fused clode (no node on the box, no `-e`), so EVERY `clode build` under | |
| # clode-native died at the gate — on pushed main, with CI fully green. The | |
| # binary that is the entire point of the Q1 split was 100% broken and | |
| # nothing said a word. This step is the standing guard. | |
| # | |
| # It used to run as the last steps of node-shim-oracle, sharing that job's | |
| # tjs + deps/claude + provider. It moved here when that job went into a musl | |
| # container (see its header): this step and the one after it BUILD AND RUN a | |
| # naude, and a naude embeds the pinned OFFICIAL Node from nodejs.org, which | |
| # is a glibc build that will not exec on alpine. So the split is along the | |
| # libc line, which is the honest place for it: the shim oracle needs a musl | |
| # reference, the naude guards need glibc, and one job cannot be both. | |
| # Cache the pinned Node naude embeds so acceptance 4 (below) downloads it | |
| # from nodejs.org at most once per pin, not every run — and a transient | |
| # nodejs.org outage reuses the cache instead of turning this step red. Keyed | |
| # on the pin file so a version bump misses and re-fetches automatically. | |
| - name: Cache the pinned Node (naude engine) | |
| uses: actions/cache@v6.1.0 | |
| with: | |
| path: ${{ runner.temp }}/clode-nodes | |
| key: pinned-node-${{ hashFiles('deps/clode/node-pin.json') }}-${{ runner.os }}-${{ runner.arch }} | |
| - name: the native builder BUILDS THE PRODUCT (node-free, real provider) | |
| id: build | |
| env: | |
| CLODE_TJS: ${{ runner.temp }}/tjs/tjs | |
| # acceptance 4 (naude) fetches the pinned Node into this persistent, | |
| # cached store instead of a per-run temp dir (test/clode-native.test.cjs | |
| # honors CLODE_NODES); acceptances 1-3 (quaude) ignore it. | |
| CLODE_NODES: ${{ runner.temp }}/clode-nodes | |
| run: | | |
| set -euo pipefail | |
| # `build --self` embeds this; without it the fuse has no payload. | |
| node scripts/build-clode-main.mjs | |
| # Provision postject into deps/clode: quaude-fuse.js carries it as a | |
| # builder member (soft — skipped if absent), and acceptance 4's naude | |
| # build needs it on disk. Without this the fused clode-native ships no | |
| # postject and the naude assembly dies "Cannot find module .../postject". | |
| npm ci --prefix deps/clode 2>&1 | tail -3 | |
| PROV="$(node scripts/stage-provider.mjs)" || exit 1 | |
| echo "provider: $PROV" | |
| # The acceptances prove node-freeness by running the fused builder with | |
| # PATH=/usr/bin:/bin, and they SKIP if a node lives there ("cannot prove | |
| # node-freeness here"). Surface that up front: if this runner image ever | |
| # puts node on the minimal PATH, the skipped-0 assertion below would fail | |
| # with a confusing message about skips instead of the real reason. | |
| if PATH=/usr/bin:/bin command -v node; then | |
| echo "ERROR: this runner has node on /usr/bin:/bin, so the node-free proof cannot run." >&2 | |
| echo " Give test/clode-native.test.cjs a synthetic minimal PATH, or run this step" >&2 | |
| echo " on an image without a system node — do NOT just drop the assertion." >&2 | |
| exit 1 | |
| fi | |
| echo "minimal PATH has no node — the node-free proof can run" | |
| # A skipped acceptance is not a pass: this test skips itself when the | |
| # provider is absent, so assert it actually RAN rather than trusting a | |
| # green exit. That distinction is the whole reason this step exists. | |
| # Both summary forms are matched on purpose — node prints "ℹ skipped N" | |
| # (spec reporter) or "# skipped N" (tap), and pinning the wrong one | |
| # would make this guard fire always or never. Verified locally: node 24 | |
| # emits the spec form even when piped. | |
| CLODE_PROVIDER_BIN="$PROV" CLODE_CLAUDE_BIN="$PROV" \ | |
| node --test --test-timeout=1200000 test/clode-native.test.cjs 2>&1 | tee native.out | |
| grep -qE '^(ℹ|#) skipped 0$' native.out \ | |
| || { echo "ERROR: a clode-native acceptance SKIPPED (or the summary format changed) — the node-free builder went unproven" >&2; exit 1; } | |
| # The packaged targets themselves: build a real naude (Node SEA) and a real | |
| # quaude (fused tjs) from the same CC and diff them. The model oracle above | |
| # runs cli.cjs under two engines; only this covers the PACKAGING. Nothing in | |
| # CI built a naude before, which is how a bug that killed every naude on its | |
| # first boot ("sea.getRawAsset is not a function") stayed green. | |
| - name: full-binary oracle — real naude vs real quaude | |
| id: oracle | |
| env: | |
| # /tjs/tjs, not /tjs: download-artifact unpacks INTO ${{ runner.temp }}/tjs, | |
| # so that dir is the artifact, and the engine is the file inside it. Pointing | |
| # CLODE_TJS at the DIRECTORY made `clode build` read it as the template — | |
| # "EISDIR: illegal operation on a directory, read". Wrong since this step was | |
| # written, and invisible twice over: the job used to die at an earlier step | |
| # (the drifted autoupdater anchor), and then skipped entirely once a hard leg | |
| # failed. The two steps above always had it right. | |
| CLODE_TJS: ${{ runner.temp }}/tjs/tjs | |
| CLODE_ORACLE_BINARIES: '1' | |
| run: | | |
| set -euo pipefail | |
| PROV="$(node scripts/stage-provider.mjs)" || exit 1 | |
| CLODE_PROVIDER_BIN="$PROV" CLODE_CLAUDE_BIN="$PROV" \ | |
| node --test --test-timeout=1200000 test/oracle-binaries.test.cjs | |
| # THE ORACLE PROPERTY the step above spends: naude is only a control for quaude | |
| # because it carries the bun-shim and NOT the node-shim. The gate for that reads a | |
| # BUILT naude's SEA asset table, so it belongs HERE, in the one job that has one — | |
| # under `npm test` it would skip forever, which is the 2026-08-25 disease (sixteen | |
| # tests skipping in CI since forever, invisibly). `skipped 0` is asserted for the | |
| # same reason clode-native's acceptances assert it: a gate that skipped is a gate | |
| # that is not there. | |
| - name: naude shim boundary — the bytes that make the oracle an oracle | |
| run: | | |
| set -euo pipefail | |
| node --test test/naude-shim-boundary.test.cjs 2>&1 | tee boundary.out | |
| grep -qE '^(ℹ|#) skipped 0$' boundary.out \ | |
| || { echo "ERROR: the naude shim-boundary gate SKIPPED — it found no built naude to inspect (see its skip message above)" >&2; exit 1; } | |
| # REQUIRED-GATE MARKER (BACKLOG item 4 / phase 5 task 10). This job "sat as one red | |
| # among twenty-two" — it BUILDS THE PRODUCT (node-free, real provider) and is the | |
| # closest thing we have to a gate on the thing users actually get; it would have | |
| # caught the zstd carve break on day one. Measured 2026-09-04, before writing this: | |
| # `gh api repos/schmonz/clode/branches/main/protection` -> 404 "Branch not | |
| # protected" — this repo has NO branch-protection required-status-checks at all, for | |
| # ANY job, so "required" in GitHub's own sense is a repo-settings action (or a | |
| # mutating `gh api`/ruleset call) outside this file's reach; a human with admin on | |
| # the repo has to flip that switch separately. What THIS file can do is make a red | |
| # here unmistakable the moment it happens. | |
| # | |
| # FIX ROUND 1 (review finding 1): a bare `if: failure()` cannot tell "the product is | |
| # broken" from "an npm registry timeout / a transient nodejs.org outage stopped us | |
| # from checking" — exactly the ambiguity this phase's doctrine forbids everywhere | |
| # else. So the loud "SHIPPED ARTIFACT BROKEN" claim is now conditioned on the | |
| # OUTCOME of the two steps that actually build/diff the product (`id: build` above, | |
| # "the native builder BUILDS THE PRODUCT"; `id: oracle`, "full-binary oracle — real | |
| # naude vs real quaude"). Any OTHER failure in this job (checkout, artifact | |
| # download, npm ci, the provider install, the cache action, or the shim-boundary | |
| # step after these two) gets the quieter, honest "could not verify" message instead | |
| # — it does not assert the product is broken, only that this job could not prove it | |
| # isn't. | |
| - name: if THE PRODUCT build/oracle failed, say so plainly (the shipped artifact is broken) | |
| if: steps.build.outcome == 'failure' || steps.oracle.outcome == 'failure' | |
| run: | | |
| MSG='native-builder-oracle failed at the build or the full-binary oracle step: this job builds THE PRODUCT node-free with a real provider (clode-native builds a quaude+naude and diffs the two packaged targets). A red HERE means the ARTIFACT USERS GET is broken -- the same class of break as the 2.1.251-era zstd carve regression this job exists to catch on day one -- not an ordinary test failure to triage whenever there is time.' | |
| echo "::error title=SHIPPED ARTIFACT BROKEN::$MSG" | |
| { echo '### :rotating_light: SHIPPED ARTIFACT BROKEN'; echo "$MSG"; } >> "$GITHUB_STEP_SUMMARY" | |
| - name: if something ELSE in this job failed, say THAT plainly (no claim about the artifact) | |
| if: failure() && steps.build.outcome != 'failure' && steps.oracle.outcome != 'failure' | |
| run: | | |
| MSG='native-builder-oracle failed, but NOT at the build or the full-binary oracle step (checkout, an artifact download, a provider/dep install, the cache action, or the shim-boundary step) -- so this job could not verify the shipped artifact this run, which is a DIFFERENT condition from proving it broken. Could be a real regression in this job'"'"'s own plumbing, or infrastructure flake (a registry timeout, a transient fetch failure). Look at which step actually failed above before assuming either.' | |
| echo "::warning title=native-builder-oracle: could not verify the shipped artifact::$MSG" | |
| { echo '### :warning: native-builder-oracle: could not verify the shipped artifact'; echo "$MSG"; } >> "$GITHUB_STEP_SUMMARY" | |
| # Linux PTY / live-render coverage — the FIRST CI run of these files on any | |
| # POSIX platform. test/live-render-helper.cjs's CLODE_LIVE_RENDER gate used to | |
| # bundle two justifications of different scope ("touches the Keychain, may | |
| # touch the network"); Keychain is darwin-only, so 803756f made the gate | |
| # platform-aware — every non-darwin platform now runs these tests BY DEFAULT. | |
| # Nothing changed that in CI until this job: the Linux `test` job above (npm | |
| # test, suite.yml) has no tjs, so every one of these files skipped there | |
| # ("no tjs binary") regardless of the gate. BACKLOG's "last 5 skips" entry | |
| # (2026-09-02, corrected 2026-09-03) counted 13 CLODE_LIVE_RENDER-gated tests | |
| # + the 3 CLODE_QUAUDE-gated update-notify tests as the addressable ~16. | |
| # | |
| # Measured before writing this job (not assumed): a Linux container (see | |
| # .superpowers/sdd/2026-09-02-phase2-name-the-steps/linux-pty-ci-report.md) | |
| # built a real fused quaude from the pinned provider and ran these exact | |
| # files/commands — quaude-build 10/10, e2e-tui-tjs 1/1, e2e-ctrlz-tui 1/1, | |
| # interactive-render-diff 2/2, interactive-resize-diff 2/2, update-notify.pty | |
| # 3/3 (11 CLODE_LIVE_RENDER tests + 3 update-notify = 14 running here). | |
| # | |
| # NOT included, out of the 13 — two fail the "must be able to fail | |
| # MEANINGFULLY" bar for a reason specific to THIS environment, not the | |
| # platform gate, both discovered by actually running them here: | |
| # e2e-doctor-parity.test.cjs (2) needs a LOGGED-IN provider profile for | |
| # /doctor to open its full-screen report | |
| # — without one it hard-FAILS (no | |
| # precondition-skip guard the way | |
| # stale-frames.pty has one): "both | |
| # /doctor renders were captured" and the | |
| # parity assertion both threw here. That | |
| # would make this job permanently red | |
| # for an environmental gap, not a | |
| # regression — a gate that CANNOT pass | |
| # is not a signal. Needs a seeded | |
| # logged-in fixture before it can join. | |
| # fidelity/stale-frames.pty.test.cjs (2) same missing-login precondition, | |
| # but this file guards it correctly | |
| # (t.skip, not a thrown assertion) — | |
| # verified 2/2 skip cleanly here. Left | |
| # out anyway: with no logged-in fixture | |
| # it would skip on every run forever, | |
| # asserting nothing for its share of | |
| # the job's runtime. | |
| # (interactive-live-turn.test.cjs's 2 are also CLODE_LIVE_RENDER-gated but | |
| # counted separately by BACKLOG — CLODE_LIVE_ONLINE, real API calls/tokens, | |
| # a different axis entirely, out of scope here on its own merits.) | |
| # | |
| # update-notify.pty NEEDS a prebuilt CLODE_QUAUDE (it is not self-building, | |
| # unlike the other files here) — one dedicated `clode build` step pays for | |
| # it: 30s measured in the (warm) verification container, likely more on a | |
| # cold CI runner — BACKLOG's own estimate for a `clode build` is ~7 minutes. | |
| # Included anyway: it is the only PTY coverage of the auto-update NOTIFY | |
| # surface (see the file's own header), and the cost is paid once and shared | |
| # by its 3 tests. | |
| linux-x64-pty: | |
| needs: [changes, tjs] | |
| # Run once `tjs` FINISHES, whatever its conclusion — do NOT skip because some | |
| # OTHER leg failed. This job consumes ONE named artifact; a leg it does not use | |
| # has no bearing on whether it can run. Plain `needs: tjs` skips the moment any | |
| # HARD leg fails, which silently deletes this signal: when haiku-x64 was promoted | |
| # to gating (2026-07-17), a single real haiku regression skipped this job and the | |
| # three windows jobs in one stroke — a broken gate masking working gates, the | |
| # exact disease that let the dep-closure P0 through (node-shim-oracle skipped at | |
| # 9e968b4 for the same reason). If the artifact this job needs is genuinely | |
| # missing, download-artifact fails it LOUDLY, which is the honest answer. | |
| # | |
| # ALSO gated on `changes`: this job downloads a `tjs` artifact, so it must | |
| # skip in lockstep with `tjs` on a docs-only push (task-7) rather than | |
| # run and fail loudly on a missing artifact. `!= 'false'` not `== 'true'` | |
| # — see `tjs`'s own comment (fix round 1, Finding 1: a dead `changes` job | |
| # must not silently skip this too). | |
| if: needs.changes.outputs.code != 'false' && !cancelled() | |
| runs-on: ubuntu-latest | |
| defaults: | |
| run: | |
| shell: bash | |
| steps: | |
| - uses: actions/checkout@v7.0.1 | |
| with: | |
| submodules: false | |
| - uses: actions/setup-node@v7.0.0 | |
| with: | |
| node-version-file: .tool-versions | |
| # linux-x64-musl is a STATIC musl build (same artifact node-shim-oracle and | |
| # native-builder-oracle already consume on this same runner OS) — it runs | |
| # fine on this glibc runner, confirmed the same way those jobs rely on it. | |
| - uses: actions/download-artifact@v8.0.1 | |
| with: | |
| name: tjs-linux-x64-musl # bare static engine from the CI-tier leg | |
| path: ${{ runner.temp }}/tjs | |
| - name: mark the engine executable | |
| run: chmod +x "${{ runner.temp }}/tjs/tjs" | |
| - name: Install the Claude provider (npm, pinned — also puts `claude` on PATH for the differentials below) | |
| run: | | |
| V=$(sed -n 's/^claude-code //p' UPSTREAM_PIN) | |
| echo "provider pin: @anthropic-ai/claude-code@$V (deliberately behind upstream — see UPSTREAM_PIN)" | |
| npm i -g @anthropic-ai/claude-code@"$V" 2>&1 | tail -3 | |
| - name: Install the runtime dep closure (the fuse's ext-dep closure) | |
| run: npm ci --prefix deps/claude | |
| - name: Install the PTY harness (node-pty + @xterm/headless) | |
| run: npm install --prefix test | |
| - name: Preflight — node-pty can open a PTY on this kernel | |
| run: node test/harness-preflight.cjs | |
| - name: quaude-build + e2e-tui-tjs + e2e-ctrlz-tui + interactive-render/resize-diff | |
| # Named explicitly rather than a glob, so a future reader can tell what | |
| # is covered without running it. Each of these files builds its OWN | |
| # throwaway quaude internally — no shared state between them, hence no | |
| # single combined `clode build` step above for this group. | |
| env: | |
| CLODE_TJS: ${{ runner.temp }}/tjs/tjs | |
| run: | | |
| set -euo pipefail | |
| PROV="$(node scripts/stage-provider.mjs)" || exit 1 | |
| echo "provider: $PROV" | |
| CLODE_PROVIDER_BIN="$PROV" CLODE_CLAUDE_BIN="$PROV" \ | |
| node --test --test-concurrency=1 \ | |
| test/quaude-build.test.cjs \ | |
| test/e2e-tui-tjs.test.cjs \ | |
| test/e2e-ctrlz-tui.test.cjs \ | |
| test/fidelity/interactive-render-diff.test.cjs \ | |
| test/fidelity/interactive-resize-diff.test.cjs | |
| # update-notify.pty is gated on a PREBUILT quaude (CLODE_QUAUDE), not on | |
| # tjs/provider directly — build one explicitly (cost: see this job's | |
| # header comment). Paid once, shared by the 3 tests below. | |
| - name: Build a quaude for update-notify.pty (CLODE_QUAUDE) | |
| env: | |
| CLODE_TJS: ${{ runner.temp }}/tjs/tjs | |
| run: | | |
| set -euo pipefail | |
| PROV="$(node scripts/stage-provider.mjs)" || exit 1 | |
| # `clode build --out` does not mkdir -p its own parent — a bare path | |
| # under a fresh runner.temp dies ENOENT after the fuse already ran. | |
| mkdir -p "${{ runner.temp }}/quaude-notify" | |
| CLODE_CLAUDE_BIN="$PROV" bin/clode build --out "${{ runner.temp }}/quaude-notify/quaude" | |
| - name: fidelity/update-notify.pty — the auto-update NOTIFY surface | |
| env: | |
| CLODE_QUAUDE: ${{ runner.temp }}/quaude-notify/quaude | |
| run: node --test --test-concurrency=1 test/fidelity/update-notify.pty.test.cjs | |
| # node-shim oracle on DARWIN — the same node-vs-tjs behavioral diff as | |
| # node-shim-oracle above, but on macOS against a real darwin tjs. | |
| # WHY A SEPARATE JOB: the node-shim is darwin-oriented (signal table, dylib | |
| # suffix, spawn env handling), so a class of divergences appears ONLY on | |
| # darwin and the linux oracle cannot see it. Concretely: a process.env mutation | |
| # did not reach a default-env spawned child under tjs — but only on darwin, | |
| # because on linux tjs mirrors env into the real environ, so node-shim-env | |
| # passed there and the bug was invisible in CI until a dev box hit it | |
| # (2026-07-18). No darwin shim run = that whole class hides. This job is the | |
| # standing guard. It also runs the child-process/core/bunshim tests the linux | |
| # oracle must EXCLUDE for musl/signal/dylib portability — on darwin they are | |
| # native behavior and pass. Consumes the darwin-arm64 leg's bare engine. | |
| node-shim-oracle-darwin: | |
| needs: [changes, tjs] | |
| # Run once `tjs` FINISHES, whatever its conclusion (same rationale as the | |
| # windows/node-shim-oracle jobs): this consumes ONE named artifact; a leg it | |
| # does not use has no bearing on whether it can run. | |
| # | |
| # ALSO gated on `changes`: this job downloads a `tjs` artifact, so it must | |
| # skip in lockstep with `tjs` on a docs-only push (task-7) rather than | |
| # run and fail loudly on a missing artifact. `!= 'false'` not `== 'true'` | |
| # — see `tjs`'s own comment (fix round 1, Finding 1: a dead `changes` job | |
| # must not silently skip this too). | |
| if: needs.changes.outputs.code != 'false' && !cancelled() | |
| runs-on: macos-latest | |
| defaults: | |
| run: | |
| shell: bash | |
| steps: | |
| - uses: actions/checkout@v7.0.1 | |
| - uses: actions/setup-node@v7.0.0 | |
| with: | |
| node-version-file: .tool-versions | |
| - uses: actions/download-artifact@v8.0.1 | |
| with: | |
| name: tjs-darwin-arm64 # bare arm64 engine from the CI-tier darwin leg | |
| path: ${{ runner.temp }}/tjs | |
| - name: Install the runtime dep closure (ws/yaml/semver/string-width/... for the shim) | |
| run: npm ci --prefix deps/claude | |
| # The parity/roundtrip oracles stage cli.cjs from a real Bun-packaged | |
| # provider; with none present they SKIP, the silent-green these jobs exist | |
| # to prevent. Same provisioning as the linux oracle. | |
| - name: Install the Claude provider (npm, pinned — the oracle's input) | |
| run: | | |
| V=$(sed -n 's/^claude-code //p' UPSTREAM_PIN) | |
| echo "provider pin: @anthropic-ai/claude-code@$V (deliberately behind upstream — see UPSTREAM_PIN)" | |
| npm i -g @anthropic-ai/claude-code@"$V" 2>&1 | tail -3 | |
| # Live-network gate (regression guard) — see the linux node-shim-oracle copy. | |
| # A credential-free HEAD proves the darwin engine can DNS + TLS-connect. | |
| - name: Live-connect smoke — the engine reaches the internet over TLS | |
| env: | |
| CLODE_TJS: ${{ runner.temp }}/tjs/tjs | |
| run: | | |
| set -uo pipefail | |
| chmod +x "$CLODE_TJS" | |
| # Raw `tjs eval` has no node-shim `process`, so signal via a marker the | |
| # shell checks (a rejected fetch -> no OK marker -> the gate fails). | |
| P='fetch("https://example.com/",{method:"HEAD"}).then(function(r){console.log("LIVE-CONNECT-OK "+r.status)}).catch(function(e){console.log("LIVE-CONNECT-FAIL "+String(e))})' | |
| for i in 1 2 3; do | |
| out=$("$CLODE_TJS" eval "$P" 2>&1 || true); echo "$out" | |
| case "$out" in *LIVE-CONNECT-OK*) exit 0 ;; esac | |
| echo "retry $i…"; sleep 3 | |
| done | |
| echo "live-connect smoke: the engine could not establish a TLS connection" >&2; exit 1 | |
| - name: node-shim oracle suite (every module diffed vs host node, under a darwin tjs) | |
| env: | |
| CLODE_TJS: ${{ runner.temp }}/tjs/tjs | |
| run: | | |
| set -euo pipefail | |
| chmod +x "$CLODE_TJS" | |
| "$CLODE_TJS" eval 'console.log("darwin-arm64 tjs alive:", tjs.version)' | |
| # EXCLUDE only node-shim-tty (needs a controlling terminal, covered by | |
| # windows-amd64-tui + the POSIX e2e). Everything else runs here — including | |
| # child-process/core/bunshim (which the linux oracle excludes for portability) | |
| # AND the new node-shim-api-surface-gate (hard-gates upstream Bun.*/require | |
| # drift against the latest provider this job installs). (Ctrl-Z moved to the | |
| # opt-in test/e2e-ctrlz-tui.test.cjs; the wiring stays in node-shim-signals.) | |
| EXCLUDE='node-shim-tty' | |
| FILES=$(ls test/node-shim-*.test.cjs | grep -vE "$EXCLUDE") | |
| PROV="$(node scripts/stage-provider.mjs)" || exit 1 | |
| echo "provider: $PROV" | |
| export CLODE_PROVIDER_BIN="$PROV" | |
| # test/credential-store-attempted.test.cjs (C1, final whole-branch review of | |
| # phase 5, 2026-09-05) is the phase's own P0 guard — "the artifact ATTEMPTS | |
| # the platform credential store" — and it had run in ZERO CI jobs: it skips | |
| # unless process.platform === 'darwin' (test/credential-store-attempted.test.cjs | |
| # itself), and neither `test` (suite.yml, ubuntu+windows only) nor `ci.yml` | |
| # gave it a darwin runner with both a real `git` on PATH and this job's two | |
| # inputs (CLODE_TJS above, CLODE_PROVIDER_BIN just set). This job is the one | |
| # place in CI those three preconditions already hold — macos-latest, a real | |
| # git, a staged provider — so it needs nothing further, the same placement fix | |
| # Task 8 made for shim-surface/guard-subcommands-gate. | |
| FILES="$FILES test/credential-store-attempted.test.cjs" | |
| node --test --test-concurrency=1 $FILES 2>&1 | tee darwin-oracle.out | |
| # A SKIPPED run of the newly-added file must be distinguishable from a clean | |
| # one — never a silent pass (same phase-5 doctrine as the linux oracle's own | |
| # skip-count assertion below it in this file). `2` is this job's PRE-EXISTING, | |
| # environment-appropriate skip count — MEASURED directly (coordinator, | |
| # 2026-09-05) against this exact 66-file set, on a real darwin-arm64 box, with | |
| # CLODE_TJS + CLODE_PROVIDER_BIN pointed at the ACTUAL pinned 2.1.251 provider | |
| # (`node --test --test-concurrency=1` over all of test/node-shim-*.test.cjs | |
| # minus node-shim-tty, plus credential-store-attempted.test.cjs): 308 tests, | |
| # 306 pass, 2 skipped, 0 failed. The two skips are `node-shim-agentic.test.cjs`'s | |
| # darwin-branch row (this job never sets CLODE_DARWIN_PROVIDER_BIN, a DIFFERENT | |
| # variable from CLODE_PROVIDER_BIN, on purpose — that row wants this repo's own | |
| # fetched provider, not the staged one) and `node-shim-roundtrip.test.cjs`'s | |
| # CLODE_LIVE_ROUNDTRIP opt-in finale. credential-store-attempted itself ran | |
| # fully (6/6 passed, 0 skipped) against the pinned provider and must not add a | |
| # third skip here — a skip there means the P0 guard went right back to running | |
| # nowhere. | |
| grep -qE '^(ℹ|#) skipped 2$' darwin-oracle.out \ | |
| || { echo "ERROR: expected exactly 2 skips (the darwin-branch row needing CLODE_DARWIN_PROVIDER_BIN, and the live-roundtrip opt-in finale). A different count means credential-store-attempted skipped (the P0 guard is back to running nowhere), or an unrelated new skip appeared — see above." >&2; grep -E 'skipped' darwin-oracle.out >&2; exit 1; } | |
| # ITEM 5 (BACKLOG / phase 5 task 10): "No CI leg builds against more than one | |
| # platform's graph. That is why a merger corruption that broke linux shipped green | |
| # on darwin." Measured 2026-09-04, before writing this: this job (the only macOS job | |
| # in ci.yml) never ran `clode build` at all — the node-shim oracle above diffs | |
| # SHIM BEHAVIOR against host node, it never stages+merges a provider. So there was | |
| # no existing build here to add a second one next to; this step IS the first. | |
| # | |
| # Bun constant-folds process.platform into a provider binary at CARVE time | |
| # (libexec/extract-claude-js.cjs's providerPlatformOf, libexec/clode-fuse.cjs's | |
| # TARGET-MATCHED ASSEMBLY gate) — so a linux-carved provider is a DIFFERENT module | |
| # graph than the darwin one every other build in this repo's CI merges, with | |
| # different dead-coded branches and different cyclic-require shapes for | |
| # libexec/graph-scc-merge.cjs to fold. The merge itself always runs under the HOST's | |
| # own tjs (CLODE_TJS), never the target's — see clode-extract.cjs's resolveEngine and | |
| # clode-fuse.cjs's own CROSS-FUSE comment ("the worker still runs under the host | |
| # template... the host cannot exec the foreign output"). So a linux graph has never | |
| # been merged by anything other than a linux host. This step forces exactly that | |
| # combination: this darwin host's tjs merging a LINUX-carved graph, cross-fusing into | |
| # a linux-x64 engine template this host only ever touches as bytes, never executes. | |
| # | |
| # The linux-x64 provider is fetched by `npm pack` (not `npm i`), because | |
| # @anthropic-ai/claude-code-linux-x64 declares os:["linux"] and a plain install | |
| # refuses on darwin — `npm pack` has no such gate, and clode only ever CARVES the | |
| # provider's bytes, never runs it (same "read the bytes, never execute" doctrine | |
| # the rest of the provider-lookup machinery relies on). | |
| # | |
| # NOTE FOR PHASE 3: `--target linux-x64` is today's invocation string (matches | |
| # test/clode-target-build.test.cjs, test/clode-manifest-fetch.test.cjs et al.) — if | |
| # phase 3's target-naming work renames it (e.g. to a fully-dashed canonical | |
| # `linux-amd64`), that is ONE LINE of churn here, not a rewrite. | |
| - uses: actions/download-artifact@v8.0.1 | |
| with: | |
| name: tjs-linux-x64-musl # a SECOND platform's engine — bytes only, never executed here | |
| path: ${{ runner.temp }}/tjs-linux | |
| - name: Stage a second platform's provider (linux-x64, via npm pack — carved, never run) | |
| run: | | |
| set -euo pipefail | |
| chmod +x "${{ runner.temp }}/tjs-linux/tjs" | |
| V=$(sed -n 's/^claude-code //p' UPSTREAM_PIN) | |
| DIR="${{ runner.temp }}/linux-provider" | |
| mkdir -p "$DIR" | |
| npm pack "@anthropic-ai/claude-code-linux-x64@$V" --pack-destination "$DIR" 2>&1 | tail -3 | |
| tar xzf "$DIR"/anthropic-ai-claude-code-linux-x64-*.tgz -C "$DIR" | |
| test -s "$DIR/package/claude" | |
| echo "LINUX_PROVIDER=$DIR/package/claude" >> "$GITHUB_ENV" | |
| - name: SECOND-PLATFORM graph oracle — cross-build a linux-x64 quaude from this darwin host | |
| env: | |
| CLODE_TJS: ${{ runner.temp }}/tjs/tjs | |
| CLODE_TARGET_TEMPLATE: ${{ runner.temp }}/tjs-linux/tjs | |
| run: | | |
| set -euo pipefail | |
| # LINUX_PROVIDER: exported via GITHUB_ENV by the step above, inherited here like | |
| # any other env var a prior step sets (same idiom as suite.yml's CLODE_PROVIDER_BIN). | |
| # `clode build --out` does not mkdir -p its own parent (see linux-x64-pty's | |
| # same note) — a bare path under a fresh runner.temp dies ENOENT. | |
| mkdir -p "${{ runner.temp }}/quaude-linux-graph-check" | |
| CLODE_CLAUDE_BIN="$LINUX_PROVIDER" \ | |
| node bin/clode build --target linux-x64 --out "${{ runner.temp }}/quaude-linux-graph-check/quaude" | |
| test -s "${{ runner.temp }}/quaude-linux-graph-check/quaude" |