Skip to content

docs(backlog): closure condition 7 has no mechanism; 3b moved the number #939

docs(backlog): closure condition 7 has no mechanism; 3b moved the number

docs(backlog): closure condition 7 has no mechanism; 3b moved the number #939

Workflow file for this run

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 }}
# SETTLED 2026-09-12 by the ModernMavericks family rule (mavericks-shipyard's
# conventions check 1b and its release-concurrency design): **a run that can PUBLISH is
# cancelled by nothing; a run that is only BUILD FEEDBACK is superseded by the newer
# commit's.** This workflow cannot publish — release.yml is tag- and dispatch-only, and
# now declares the never-cancelled half explicitly — so ci.yml is the feedback half, on
# main as much as anywhere, and `true` is unconditional here by that rule.
#
# It replaces `${{ github.ref != 'refs/heads/main' }}` (2026-09-04), whose evidence was
# real and is not being denied: 194e237, a BACKLOG.md-only commit, cancelled the run
# carrying 06c6c96, a real test fix. What changed is the cost — after the fast/slow
# matrix split and the smoke decoupling the suite reports in ~1-3 min and the fast legs
# in ~6, against a 113-minute time-to-oracle before — and the discovery of never-cancel's
# own cost: consecutive pushes queue behind each other's 22 qemu legs (run 33970393353
# sat pending until 33966318977 was cancelled by hand, 2026-09-05).
#
# KNOWN RESIDUAL, recorded rather than papered over: `concurrency` is resolved at RUN
# level, before the `changes` gatekeeper below can see the diff, so a docs-only push
# still cancels a code run — and, because it then skips the heavy matrix, the run that
# replaces it carries LESS signal, which is the one place this family rule's premise
# ("the newer run reproduces the feedback") does not hold here. Recovery is
# `gh run rerun <id>`. The fix that would remove it, workflow-level `paths-ignore`, is
# deliberately NOT taken: it is a DECLARED path list whose failure mode is a code push
# creating no run at all, silently — the opposite of `changes`'s derived, fail-open
# default. See BACKLOG.md; if this bites, that entry is where the trade gets re-opened.
cancel-in-progress: true
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+blobulate+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-blobulate 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
# tjs-fast-plan/tjs-fast-smoke below own the deferred check — see
# tjs-legs.yml's defer-smoke input for why this is the ONE call site
# allowed to set it.
defer-smoke: "true"
# 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
# Full smoke, DEFERRED (measured run 33959009299 baseline vs. current-HEAD
# job 101287474377, `tjs / leg (linux-x64-musl)`): the engine artifact every
# downstream consumer needs was complete at ~9s into that job, but the leg
# then spent ~8.3 more minutes (of an 8.55min total) on build-leg's own
# full-smoke step (a ~419s ephemeral-quaude blobulate + MCP probe + rg
# inventory) — work no downstream job consumes. `needs:` on a `uses:` call
# is JOB-level and atomic: `tjs` (this workflow's call to tjs-legs.yml,
# tier:ci/only:fast) does not finish until ALL SEVEN of its fast legs do,
# so every one of them paying that ~7min tax — not only the four legs the
# oracle/Windows jobs actually download — was what held up every
# `needs: tjs` job below. The `tjs` job above now passes
# `defer-smoke: "true"` (tjs-legs.yml's own input, forwarded to build-leg
# as ci-fast-defer-smoke), which skips build-leg's full-smoke for every
# leg in that one call; `tjs` now finishes once each leg's builder is
# blobulated and uploaded, not once it's also been smoked.
#
# These two jobs run that SAME check (the .github/actions/full-smoke
# composite — one source, so build-leg's synchronous copy and this
# deferred copy cannot check different things) against the artifact
# `tjs`'s legs already uploaded, instead of re-blobulating from source. The
# smoke still runs on every push and can still fail the run — it fails
# THIS job instead of `leg`, which is the whole point: nothing downstream
# needs THIS job to finish, only `tjs` (now fast) does.
#
# plan mirrors tjs-legs.yml's own `plan` job (same script, same tier/only
# pair) so the leg list here can never drift from what `tjs` actually
# built — scripts/tjs-legs.mjs stays the one source; nothing here declares
# a leg name of its own.
tjs-fast-plan:
needs: changes
if: needs.changes.outputs.code != 'false' && !cancelled()
runs-on: ubuntu-latest
outputs:
legs: ${{ steps.legs.outputs.legs }}
steps:
- uses: actions/checkout@v7.0.1
- uses: actions/setup-node@v7.0.0
with:
node-version-file: .tool-versions
- id: legs
run: echo "legs=$(node scripts/tjs-legs.mjs ci fast)" >> "$GITHUB_OUTPUT"
tjs-fast-smoke:
needs: [changes, tjs, tjs-fast-plan]
# Gated on `tjs` (not just tjs-fast-plan) so this never races the leg
# job's own artifact upload; gated on `changes` for the same reason every
# artifact-downloading job here is (task-7, docs-only push skips in
# lockstep). `!= '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()
strategy:
fail-fast: false
matrix:
include: ${{ fromJSON(needs.tjs-fast-plan.outputs.legs) }}
runs-on: ${{ matrix.os }}
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v7.0.1
- uses: actions/setup-node@v7.0.0
with:
node-version-file: .tool-versions
# Same computation build-leg's own "Name the builder artifact" step
# does (scripts/canonical-name.cjs is the one source for both) — the
# artifact name must match EXACTLY what that step uploaded, floor
# included (only darwin-arm64 carries one in the fast tier).
- name: Name the builder artifact (matches build-leg's own naming)
id: name
run: |
set -euo pipefail
if [ "${GITHUB_REF_TYPE:-}" = tag ]; then V="${GITHUB_REF_NAME#v}"; else V="$(cat VERSION)"; fi
ASSET="$(node scripts/canonical-name.cjs asset '${{ matrix.leg }}' "$V" '${{ matrix.floor || '' }}')"
echo "asset=$ASSET" >> "$GITHUB_OUTPUT"
# ci tier never sets `publish` (legsFor('ci') strips it — see
# scripts/tjs-legs.mjs), so build-leg's own upload is always the
# smoke- prefixed name, never the bare release name.
- uses: actions/download-artifact@v8.0.1
with:
name: smoke-${{ steps.name.outputs.asset }}
path: .
- name: Restore the executable bit (artifact upload/download can drop it)
run: chmod +x "${{ steps.name.outputs.asset }}" || true
- uses: ./.github/actions/full-smoke
with:
asset: ${{ steps.name.outputs.asset }}
# 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+blobulate+PONG+
# publish (subsuming the old blobulate-build/blobulate-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 blobulated 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 node` 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-build.cjs's stageUpstreamCli) — without paying for a
# whole quaude blobulate 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 blobulate'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: blobulate 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 blobulated builder process.execPath IS the
# blobulated 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
# `clode bootstrap` embeds this; without it the blobulate has no payload.
node scripts/build-clode-main.mjs
# Provision postject into deps/clode: quaude-blobulate.js carries it as a
# builder member (soft — skipped if absent), and acceptance 4's naude
# build needs it on disk. Without this the blobulated 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 blobulated 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 (blobulated 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 blobulated 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 builds its own quaude now (built-binary.cjs's
# builtQuaude(), like the other files here); CLODE_QUAUDE is an OPTIMIZATION
# that skips that build when a prebuilt binary is already on hand. The
# dedicated `clode build` step below is therefore a cost saver, not a
# precondition — 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 because 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 blobulate'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 blobulate already ran.
mkdir -p "${{ runner.temp }}/quaude-notify"
CLODE_CLAUDE_BIN="$PROV" scripts/stage0.mjs 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-build.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-build.cjs's own CROSS-BLOBULATE 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-blobulating 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 scripts/stage0.mjs build --target linux-x64 --out "${{ runner.temp }}/quaude-linux-graph-check/quaude"
test -s "${{ runner.temp }}/quaude-linux-graph-check/quaude"