mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 14:38:59 +02:00
v1.77.0.0 feat: test-infrastructure overhaul wave 1 — matrix deletion, flake telemetry, sync-spawn wedge class extinct (#2746)
* fix: pin the claude CLI to an exact version in the CI image + tripwire The image installed @anthropic-ai/claude-code UNPINNED and rebuilt weekly 'to pick up CLI updates' — while bun sat carefully pinned at 1.3.13 two RUN lines above. The PTY harness screen-scrapes this CLI's TUI, and that drift broke it three separate times (welcome-screen wedge on 2.1.233, skillify HOME discovery on 2.1.237, guard/freeze hooks on 2.1.162), each debugged as a flake first. Pin 2.1.251 (current latest), bump deliberately via a PR that runs the PTY gate, and enforce with test/ci-image-cli-pin.test.ts: any global npm install in Dockerfile.ci without an exact @X.Y.Z pin fails the free suite. The weekly ci-image cron stays as a cheap tag self-heal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: stamp the claude CLI version into every eval-store run record Three harness breakages were traced to claude-CLI TUI drift only after long flake hunts, because no run record said which CLI it actually exercised. EvalCollector now stamps claude_cli_version (claude --version, cached once per process, 'unknown' when the binary is absent) into both partial and finalized records — schema-additive optional field, no SCHEMA_VERSION bump. Correlating a flake wave with a CLI release becomes a grep over ~/.gstack/projects/<slug>/evals/ instead of archaeology. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: give the spinning-shard kill test load headroom (30s -> 90s) The test spawns and group-kills three real children (one a busy-loop burning a full core) while five sibling shard processes compete for eight vCPUs. Under full-suite load it blew bun's default 30s per-test ceiling at 30,009ms — while passing in isolation in 1.4s — and red the only required lane. Every assertion in it is event-based (statuses, group-kill proof, heartbeat lines); the sole latency claim is the <30s kill-deadline sanity bound, which stays. Explicit 90s headroom, not a weakened oracle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: green-by-skip census — skip counts in the classifier, all-skipped labeling in the paid runner bun's 'Ran N tests' line COUNTS skipped tests, so a codex/gemini shard whose every test self-skipped (binary absent on the runner — true of every CI runner today) exits 0, dodges the hollow-shard guard, and reads as coverage in the weekly census. The classifier now parses bun's ' N skip' / ' N pass' recap lines; ShardOutcome carries skippedTests; formatSummary and the fail-closed slices report label an all-skipped pass explicitly: 'all N tests SKIPPED — verified nothing'. Status stays 'passed' (external service availability is host state, not a repo regression) but the census can no longer mistake absence for coverage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: extract composite actions for eval-lane setup; surviving lanes gain the fail-fast registry verification 'Fix bun temp' x3, 'Restore deps' x5, 'Seed claude interactive config' x3, and 'Register gstack skills' x3 were byte-near-identical copies across the legacy matrix, the sliced lane, and the periodic lane — and only the MATRIX copy of register-skills carried the 19-line dangling-symlink + frontmatter fail-fast loop written after a silent 'Unknown command' + 35-min-timeout incident. Extract all four into .github/actions/ composites; the register composite carries the verification loop (generalized over the skill list), so the sliced and periodic lanes — the lanes that SURVIVE the matrix deletion — now inherit the check they had silently dropped. Matrix-job inline copies are left untouched: that job is deleted next. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: delete the legacy 17-row eval matrix — the sliced lane is the only paid lane Every PR paid twice: the hand-enumerated matrix (18 test files, 22.6 min, ~$21 API measured on run 33263204465) ran serialized AHEAD of the strictly superior sliced lane via 'needs: evals' — 35.5 min wall and ~2x paid spend for the same diff. 14 of 17 rows carried no tier:, so periodic Opus benchmarks leaked into every PR (the e2e-plan row alone: 12/12 tests, 21.7 min, $7.28 — the wall-clock bound of ALL of CI). Parity receipt (static, pre-deletion): the sliced lane's gate census (49 files, derived from the runner itself) strictly contains all 18 matrix test files, plus 31 files the matrix never ran. Pure deletion — one revert restores it. The PR comment moved into slices-report (same '## E2E Evals' upsert marker, now sourced from slice artifacts + carrying the fail-closed reconciliation verdict). plan-slices loses the needs edge; the dead workflow-level EVALS_TIER env goes with it. test/evals-workflow-matrix.test.ts (and its KNOWN_MATRIX_GAPS / KNOWN_TIER_UNSET burn-down ratchets — retired: the sliced census makes 'every gate file runs' true by construction) is rewritten as test/evals-workflow-wiring.test.ts: matrix stays deleted, planner/executor/ report tier + slice-count agreement, both surviving lanes on the shared register-skills composite with its fail-fast verification loop, PR comment survival. Expected: PR eval wall 35.5 -> ~13 min, per-PR paid spend ~halved. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: provider-runner timeouts kill the whole process GROUP; codex/gemini inherit the orphan-drain hardening All three provider runners (claude/codex/gemini) killed only the direct child on timeout: tool subprocesses the CLI spawned survived as orphans holding our pipes open and burning shared API rate (observed: a 600s timeout stretching past 1400s; a stalled run once burned a core for 15 hours). gstack-detach's watchdog had the same shape one level up — killpg SIGTERM, 5s grace, then a direct-child proc.kill() that orphaned grandchildren. Fix: spawn provider children via node:child_process with detached (own process group) and killProcessGroup(SIGKILL) in the timeout handler — runShardChild's proven pattern, EPERM/ESRCH fallbacks included. The codex and gemini copies also gain the reader.cancel() + stderr Promise.race hardening only the claude copy had (they still carried the blocked-drain hang it fixed). gstack-detach's watchdog now group-SIGKILLs after the grace. Regression net: test/session-runner-groupkill.test.ts drives the REAL runSkillTest against a fake claude shim (PATH override) that spawns a grandchild and wedges — the run must classify timeout within budget and leave neither shim nor grandchild alive — plus source pins on all three runners (detached + killProcessGroup, no bare timeout kill, no Bun.spawn reversion). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: skill-e2e-opus-47 renders SKILL.md fixtures into a mkdtemp — never the live tree mkEvalRoot ran gen-skill-docs with cwd=ROOT, regenerating every in-repo SKILL.md mid-run while concurrent paid shards copyFileSync those same files in their beforeAll (EVALS_JOBS>=4 locally, 2 per CI slice) — a sibling could capture a half-regenerated or opus-rendered SKILL.md, and a timeout before afterAll stranded the whole tree at the wrong model for every later shard. A cross-shard race that could flake ANY concurrent paid test. Render via the --out-dir flag gen-skill-docs grew for exactly this reason (mirrors the repo layout, which is all the fixture reads), read the skill heads from the render dir, delete it, and drop the afterAll restore-regen entirely. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: claude CLI version resolves in the runner parent, never on a test thread Eng-review finding: getClaudeCliVersion's fallback is a SYNCHRONOUS spawnSync on the same thread that polls concurrent PTY/session tests — the judgePtyState blocking class this overhaul kills elsewhere. The paid runner parent now resolves it once (cached) and stamps GSTACK_CLAUDE_CLI_VERSION into every shard's env; eval-store short-circuits on the env var, and the fallback spawn's budget tightens 10s -> 3s (bounded one-time stall, records 'unknown' on a slow CLI). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: wire skippedTests end-to-end through runPaidShard The census unit tests hand-built outcomes and the classifier tests parsed strings; nothing proved a real child's ' N skip' recap flows into outcome.skippedTests and the formatSummary label. A commandFor fake now prints the recap shape and the test asserts the parsed counts, the all-skipped predicate, and the 'verified nothing' label. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: make the setup composites rerun-safe (codex diff-review hardenings) restore-deps: 'cp -r SRC node_modules' with an existing node_modules NESTS the copy and leaves stale deps active — rm first. register-gstack-skills: 'ln -snf' hard-errors under set -eu when a REAL directory occupies the gstack slot — clear a non-symlink leftover first. CI workspaces are fresh today; a reusable composite must survive dirty reruns. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: sweep — every sync spawn in the test trees carries a timeout (436 sites, 157 files) spawnSync/execSync/Bun.spawnSync BLOCK the main thread, so bun's in-process per-test timeout can never fire while one waits — a hung child (stdin read, network probe, dead daemon) wedges the whole shard until the runner's external wall-clock SIGKILL. This exact class reached main: free-tests run 33262077256, test/gstack-memory-ingest.test.ts (normally 2.3s) held shard 2 at the 360s wall while its five siblings finished in ~65s. Mechanical sweep in two waves (12 + 4 fan-out agents, every edit verified against its call site): default timeout: 30_000 (matches the free runner's per-test budget), 120_000 for genuinely slow ops (installs, builds, playwright, provider CLIs), helper wrappers fixed ONCE where call sites route through them. Sites that only LOOK like calls (string fixtures, grep needles, comments) were skipped with reasons — the enforcement commit that follows marks them exempt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: sync-spawn timeout tripwire — the wedge class stays extinct Free scanner over all test trees (test/, browse/test/, design/test/, make-pdf/test/, ios-qa, browser-skills): every spawnSync/execSync/ Bun.spawnSync call site must carry a timeout within a 30-line options window, or an explicit '// tripwire-exempt: <reason>' marker. Comment lines are skipped; exemptions are counted and ratcheted shrink-only (ceiling 6 = the 6 string-fixture/grep-needle sites where the pattern is CONTENT, not a call — marked in this commit). A scan-sanity test pins that the scanner still sees >100 real call sites so it can never rot to a vacuous green. Companion to the 436-site sweep in the previous commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: paid-lane flake telemetry — record-level attempts, flaky_retries, report surfacing bun --retry leaves a retried pass INVISIBLE in its output: a fail-then-pass prints the error detail but no (fail) result line and recaps as a clean pass (probed live on 1.3.10). So attempts are recorded where they cannot lie: EvalCollector.addTest stamps a 1-based attempt on same-name re-records (a retried test runs its body again and re-records), finalized runs carry flaky_retries, printSummary warns loudly, and the fail-closed slices report lists every passed-only-on-retry test — recorded and ranked, never blocking and never silent. Cross-model confirmed (codex reached the same don't-parse -the-stream conclusion independently). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: free-lane flake ledger — retry ON in CI, flaky-passes recorded and uploaded The runner's attribution-gated flaky-retry pass (cap 5, truncation veto) was OFF in the required lane and its FLAKY-PASS evidence was console-only — so a single timing flake red the merge gate while repeat offenders stayed unenumerable. free-tests.yml now sets GSTACK_FREE_RETRY_FLAKY=1 and points GSTACK_FLAKE_LEDGER at runner.temp; every flaky-pass appends a JSONL entry (SINGLE writer: the parent runner — no concurrent-append hazard by construction; fail-open with a loud warning so a broken ledger can never red the lane) and the artifact uploads UNCONDITIONALLY — a flaky-pass run is green, which is exactly when the evidence matters. Wiring pinned by free-tests-workflow-wiring; ledger behavior unit-tested incl. the fail-open path. Matches 2026 industry practice (retry for data, quarantine out of merge-blocking but never out of logging) with the repo's own receipts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: eval:flake-rank — the flake-telemetry dial Aggregates per-test series across every finalized eval-store run (shard dirs included) plus the free flake ledger: runs, fails, RETRIED PASSES (the flake signature), avg duration — ranked retries-first. This is the readable dial behind two policies: a flaky pass never blocks a merge but is always ranked here, and the WS16 required-check promotion needs weeks of clean flake-rank, not vibes. --json for machines, --dir for downloaded CI artifacts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: two-phase session timeout — silent APIs die at the startup grace, named The single spawn-armed timer charged API queue latency to the work budget: the recurring '0 turns / $0.00 / x3 attempts' failure with four budget-bump receipts (180->300s, 240->360s, 300->420s, 90->300s). Split: startup phase (no NDJSON byte yet) kills EARLY at min(grace, timeout) with the distinct exitReason 'timeout_startup' — an availability verdict, not transcript archaeology — and the work phase arms on the first byte for the REMAINING budget, so total wall never exceeds the timeout (tier envelopes are margin-free: tests pass timeout: CAPTURE_MS and bun-budget the same tier). Local grace 90s (observed queue latency 60-90s), CI floor 300s (TODOS-filed; shared runners queue harder), both pinned by the new grace tests with fake -claude shims covering the late-first-byte and silent-API paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: census integrity — 17 phantom selection keys deleted, reverse invariant added, gitignored dep patterns replaced, local map forks derived The merge-blocking gate census counted tests that could not run. Deleted (critic-verified against both quoted-occurrence and dep-registration liveness): 7 *-prosons-format keys with no declaring test, ship-plan- completion/-verification, review-plan-completion, design-shotgun-path/ session/full, autoplan-core (dead ~10 months), e2e-harness-audit (its namesake is a FREE-suite file), plus 2 dead LLM-judge keys and 2 free-file keys (budget-regression-pty, global-discover) misplaced in the PAID maps. Census: 191 -> 174 keys, gate 86 -> 78 honest. The new reverse invariant in touchfiles.test.ts makes the class structurally impossible: every key must be quoted in a living paid test file OR registered to an existing paid test file via its dep list (the constructed- name binding the 2026-08 self-registration sweep established) — zero exceptions needed today, with a live-file check on any future exception. Also: '.agents/skills/**' dep patterns replaced with the generator (scripts/gen-skill-docs.ts) — .agents/ is gitignored, so those patterns could NEVER match a git diff and review-template edits silently stopped selecting codex/gemini tests; the codex/gemini local touchfile maps are now DERIVED from the canonical map (loud throw if a key vanishes) instead of hand-forked copies that had already drifted. ios-qa-e2e demoted gate -> periodic: its gate declaration was never executable in CI (hardware exclusion only applies at tier=periodic), so every Linux PR planned a hollow shard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: routing journeys lose their answer key and end at the routing decision The journey tests exist to catch skill-DESCRIPTION regressions (touchfiles: */SKILL.md.tmpl), but the fixture CLAUDE.md shipped an explicit prompt->skill lookup table — with the answer key in context, a badly regressed frontmatter description still routed correctly, so the tests could not fail on the exact class they select for. The fixture now carries only the generic invoke-skills nudge; the frontmatter carries the routing load. Also capped all 10 journeys at maxTurns 2 / tools [Skill, Read]: only the FIRST Skill call is asserted, so 5 turns of Read/Bash/Glob/Grep was pure spend — roughly halves each journey's cost. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: retire decided A/B experiments; vendor the pre-cut fixture; ban raw-SHA fixtures Three one-shot decision experiments kept re-running weekly as N=1 stochastic comparisons — flaky by construction with near-zero remaining information: skill-e2e-auq-repetition-cut-ab (its own header: gate "passed pre-landing, approved 2026-08-25"), skill-e2e-preamble-script-ab ("demoted post-Phase-3"), and opus-47's fanout arm-vs-arm (parA >= parB across two SINGLE stochastic runs — a coin flip). Deleted, with their selection keys; the SDK overlay-harness stays as the maintained instrument for the next experiment, and opus-47 keeps its routing-precision cases. verboseSkill() now reads the VENDORED test/fixtures/auq-pre-cut-...-SKILL.md instead of `git show ab66193e^:...` — a branch-local ref that dies on branch prune and already failed on shallow clones. New free tripwire (test/git-ref-fixture-tripwire.test.ts) bans the raw-SHA fixture class outright: quoted SHA:path rev-specs and gitRef-style hex defaults in the test trees fail the suite with the vendor-instead instruction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: demote plan-ceo-review-expansion-energy to periodic Opus generator + a subjective 2-axis >=4/5 LLM-judge threshold sat in the MERGE-BLOCKING gate — the exact class its sibling posture tests were demoted for, with a receipt (a +21-line preamble change once flipped the score). CLAUDE.md's own tiering rule: Opus model test -> periodic. The weekly lane keeps the regression signal; merges stop paying a judge- temperament tax. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: paid shards get per-shard TMPDIR + CHROMIUM_PROFILE isolation and a kill-path cleanup backstop The free runner treats this isolation as MANDATORY (two concurrent shards on one Chromium profile kill each other's browser; shared tmp cross-contaminates) — the paid lane had none of it. Doubly load-bearing here: a shard that hits its 30-min wall is group-SIGKILLed, so per-test afterAll cleanup never runs; the rmSync backstop is the only thing keeping wedged runs from accumulating full git-repo workspaces in the shared tmpdir forever. This is the DAG prerequisite for raising EVALS_JOBS (next commit) — more concurrency on shared state amplifies exactly the shared-tree race class opus-47 exhibited. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: paid-runner defaults 4x4 -> 8x2 — halve the local gate worst case 39 of 75 skill-e2e files hold exactly ONE test, so within-shard concurrency was dead weight for most shards: 4 jobs x 4 concurrency yielded only ~4-6 real in-flight sessions and a 13-wave local gate worst case (~6.5h). 8 jobs x 2 gives ~10-13 in-flight — under the documented-safe ~15 — and ~7 waves (~3.3h worst case). CI lanes keep their explicit EVALS_JOBS env (2 per slice; 4 for gate-census); this changes local defaults. Rollback trigger: sustained 429 storms in the WS1 telemetry across 2 PR cycles. test/eval-detach-timeout-floor.test.ts recomputed green (the raise LOWERS the worst-case floor). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: SHA-pin every action in the secrets-bearing eval lanes evals.yml and evals-periodic.yml execute PR-authored code with three provider API keys in env, yet rode mutable action tags (@v7/@v8/@v2/@v4) — while quality-gate.yml, osv-scanner.yml, and dependency-review.yml already model the SHA-pin pattern. All 30 uses sites across both lanes now pin the exact commit (tag noted in a trailing comment); dependabot's github-actions ecosystem keeps them fresh via PRs instead of silent tag moves. Pulled forward from the plan's endgame on the CEO-review + outside- voice agreement: supply-chain pins on secret lanes go first, not last. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: sweep wave 3 — the execFileSync family gets timeouts (90 sites, 17 files) The tripwire's regex covered spawnSync/execSync/Bun.spawnSync but not execFileSync — an entire blocking sync-spawn API family that could reintroduce the shard-wedge class undetected (ship review army). Same mechanical recipe as waves 1-2: timeout: 30_000 default, 120_000 for slow ops, shared wrappers fixed once, string-needle sites skipped with reasons. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: review-army + adversarial test hardening - Tripwire scans execFileSync too (ceiling 8: two more grep-needle string exemptions); merge-introduced timeout-less spawnSync in question-preference-hook fixed — the tripwire caught a site that landed on main AFTER the sweep, on its first day. - gstack-detach gains TWO watchdog kill regression tests: TERM-immune grandchild (the killpg-after-grace escalation) and the leader-dies variant (the pgid-at-spawn fix — the case the first test cannot see). - eval-flake-rank gets its unit suite (final-attempt accounting, artifact exclusion, shard recursion, recency bound). - Groupkill/startup-grace shim markers are per-run unique (pid-suffixed sleep durations): sibling Conductor worktrees run free suites with no machine lock, and fixed markers let one run pgrep/pkill the other's shims — a cross-run flake inside the anti-flake tests. - flake-ledger test pins the project-scoped local default; stale empty section headers in touchfiles-data deleted (they invited entries under deliberately retired categories). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: adversarial-review runtime fixes across the telemetry + kill paths - session-runner: exit-labeling keys off 'exit', not 'close' — an orphan holding the pipes could relabel a REAL exit (auth failure) as 'timeout_startup' availability noise; the kill path still always group-kills and cancels the reader (labeling and unblocking are separate concerns). Work phase arms on a flag, not firstResponseMs===0 (a same-ms first byte left the startup timer live all run). The CI startup grace is now a real FLOOR (Math.max), matching its name and pinning test. - gstack-detach: pgid captured AT SPAWN (== child pid under start_new_session) — resolving it after the grace raised ESRCH once the leader died on SIGTERM, orphaning TERM-immune grandchildren forever. - test-free-shards: ledger entries carry branch + git_sha (rev-parse split: '--abbrev-ref HEAD HEAD' printed the branch twice and recorded it as the sha); local ledger default is per-PROJECT, not the machine-global tmpdir. - eval-flake-rank: per-LINE ledger parse (one torn JSONL line vanished the whole series), 60-day recency bound (transcript-bearing files are MBs), shared isFinalizedEvalResultFile predicate (the artifact-taxonomy rule lived in three places); eval-store exports the predicate and finalize stops computing flakyRetries twice; paid-shards cleanup uses async rm (a SIGKILLed shard's git-workspace teardown blocked every sibling's stream classification on the parent event loop). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: CI trust-boundary + fail-closed repairs (adversarial findings) - Token/exec separation restored: slices-report (runs PR-authored code: bun install + the reconcile runner) drops to contents:read; the PR comment moves to a NEW slices-comment job holding the write token with ZERO repo code — no checkout, no bun, only downloaded artifacts + jq/gh. $GITHUB_ENV/BASH_ENV persistence is job-scoped, so the split is the boundary. The matrix-era report job had this property; the consolidation had regressed it. Pinned by the wiring test. - Reconcile exit captured via PIPESTATUS[0] in BOTH lanes: GitHub's default run-step shell has no pipefail, so `$?` after `| tee` was tee's exit — the fail-closed gate was silently fail-open. Wiring test pins it. - PR comment: final-attempt accounting restored the dropped COST accumulation (the dial read $0 forever), flaky passes render as the warning they are (never as failures), and a malformed tests[] artifact skips that file instead of aborting the whole comment under bash -e. - Remaining mutable action tags pinned (free-tests upload-artifact, ci-image checkout/docker trio — the image publisher holds packages:write and feeds the secret-bearing lanes). restore-deps fallback installs --frozen-lockfile; register-gstack-skills validates skill names before its rm -rf. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: bump version and changelog (v1.77.0.0) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: update project documentation for v1.77.0.0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: cross-model doc-review fixes — flake-ledger env knobs, CI retry-on note, stale version comment Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: correct CHANGELOG receipt numbers to measured values Gate census keys: 78 -> 77 (bun-imported E2E_TIERS count). Sweep receipt: 586 sites/176 files -> 499 sites/146 files, measured by running this branch's spawnsync-timeout-tripwire against origin/main (exit 1, 499 violations across 146 unique files; green on this branch). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: slices-comment creates the PR comment via REST — the write-token job has no git context The token/exec split gives slices-comment NO checkout by design, and gh's pr-comment subcommand resolves the repo FROM git — it died with 'not a git repository' on PR #2746's first run (the update-existing PATCH path was already explicit-repo REST and worked). Create now posts through gh api repos/.../issues/N/comments, and the wiring test pins that no git-context-requiring comment call can creep back into the job. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: startup-grace probes clear CI for local semantics; new probe pins the floor clamp The two shim probes pass explicit 2s/4s graces, but in CI the runner clamps any explicit grace up to the 300s floor (deliberate adversarial-review fix), so 'silent API killed at the grace' died at the 30s work cap instead of 2s — a deterministic red on every CI run, green locally. The probes now pin LOCAL semantics with CI cleared (same save/restore pattern as their PATH shim), and a fourth probe pins the clamp itself: CI=1 + 2s grace + 6s timeout must kill at the 6s cap, still in the startup phase — proof an explicit low grace cannot bypass the floor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
253d1dfe26
commit
e76f65a8da
@@ -1,8 +1,12 @@
|
||||
name: Build CI Image
|
||||
on:
|
||||
# Rebuild weekly (Monday 4am UTC) to pick up CLI updates — deliberately 2h
|
||||
# BEFORE evals-periodic's 6am cron so the weekly eval run finds a fresh
|
||||
# image instead of racing a half-pushed tag or duplicating the build.
|
||||
# Weekly self-heal (Monday 4am UTC) — deliberately 2h BEFORE
|
||||
# evals-periodic's 6am cron so the weekly eval run finds the image instead
|
||||
# of racing a half-pushed tag. With the claude CLI pinned in Dockerfile.ci
|
||||
# (v1.77+), this cron no longer pulls CLI updates: when the content-hash
|
||||
# tag already exists it's a ~30s no-op, and it only rebuilds if the tag
|
||||
# was somehow lost. CLI bumps happen by editing the Dockerfile pin in a PR
|
||||
# that runs the PTY gate against the new TUI.
|
||||
schedule:
|
||||
- cron: '0 4 * * 1'
|
||||
# Rebuild on Dockerfile or lockfile changes. package.json is deliberately
|
||||
@@ -32,7 +36,7 @@ jobs:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
# Copy lockfile + package.json into Docker build context
|
||||
- run: cp package.json bun.lock .github/docker/ && cp -R patches .github/docker/patches
|
||||
@@ -44,7 +48,7 @@ jobs:
|
||||
- id: meta
|
||||
run: echo "tag=ghcr.io/${{ github.repository }}/ci:${{ hashFiles('.github/docker/Dockerfile.ci', 'bun.lock', 'patches/**') }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: docker/login-action@v4
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -65,10 +69,10 @@ jobs:
|
||||
# Registry cache export needs a docker-container builder — the default
|
||||
# `docker` driver hard-errors on cache-to.
|
||||
- if: steps.check.outputs.exists == 'false'
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
|
||||
|
||||
- if: steps.check.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
with:
|
||||
context: .github/docker
|
||||
file: .github/docker/Dockerfile.ci
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
outputs:
|
||||
image-tag: ${{ steps.meta.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- id: meta
|
||||
# Keep in sync with evals.yml — key on Dockerfile + lockfile only
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
# Byte-identity pinned by test/ci-image-tag-binding.test.ts.
|
||||
run: echo "tag=${{ env.IMAGE }}:${{ hashFiles('.github/docker/Dockerfile.ci', 'bun.lock', 'patches/**') }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: docker/login-action@v4
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -61,10 +61,10 @@ jobs:
|
||||
# Registry cache export needs a docker-container builder — the default
|
||||
# `docker` driver hard-errors on cache-to.
|
||||
- if: steps.check.outputs.exists == 'false'
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
|
||||
|
||||
- if: steps.check.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
with:
|
||||
context: .github/docker
|
||||
file: .github/docker/Dockerfile.ci
|
||||
@@ -90,24 +90,19 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
options: --user runner
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Restore deps
|
||||
run: |
|
||||
if [ -d /opt/node_modules_cache ] && diff -q /opt/node_modules_cache/.bun.lock bun.lock >/dev/null 2>&1; then
|
||||
cp -r /opt/node_modules_cache node_modules
|
||||
else
|
||||
bun install
|
||||
fi
|
||||
uses: ./.github/actions/restore-deps
|
||||
|
||||
- name: Emit run manifest (ALL periodic tests minus reasoned excludes)
|
||||
env:
|
||||
EVALS_ALL: "1"
|
||||
run: EVALS_TIER=periodic bun run scripts/test-paid-shards.ts --tier periodic --emit-plan /tmp/paid-plan/manifest.json --slices 6
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: paid-plan
|
||||
path: /tmp/paid-plan/manifest.json
|
||||
@@ -133,7 +128,7 @@ jobs:
|
||||
matrix:
|
||||
slice: [1, 2, 3, 4, 5, 6]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
# Full history: files with SELF-derived selection (the LLM-judge
|
||||
# map, routing) walk git at module load, and selection is
|
||||
@@ -144,73 +139,27 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Fix bun temp
|
||||
run: |
|
||||
mkdir -p /home/runner/.cache/bun
|
||||
{
|
||||
echo "BUN_INSTALL_CACHE_DIR=/home/runner/.cache/bun"
|
||||
echo "BUN_TMPDIR=/home/runner/.cache/bun"
|
||||
echo "TMPDIR=/home/runner/.cache"
|
||||
} >> "$GITHUB_ENV"
|
||||
uses: ./.github/actions/fix-bun-temp
|
||||
|
||||
- name: Restore deps
|
||||
run: |
|
||||
if [ -d /opt/node_modules_cache ] && diff -q /opt/node_modules_cache/.bun.lock bun.lock >/dev/null 2>&1; then
|
||||
cp -r /opt/node_modules_cache node_modules
|
||||
else
|
||||
bun install
|
||||
fi
|
||||
uses: ./.github/actions/restore-deps
|
||||
|
||||
- run: bun run build
|
||||
|
||||
# Any slice can host a PTY test — seed + registration run
|
||||
# unconditionally (idempotent; mirrors evals.yml's sliced lane).
|
||||
# unconditionally (idempotent; mirrors evals.yml's sliced lane). The
|
||||
# register composite carries the fail-fast dangling-symlink/frontmatter
|
||||
# verification loop — this lane previously LACKED it, so a moved skill
|
||||
# target surfaced as a silent "Unknown command" + wedged PTY session.
|
||||
- name: Seed claude interactive config
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
run: |
|
||||
node -e '
|
||||
const fs = require("fs"), os = require("os"), path = require("path");
|
||||
const p = path.join(os.homedir(), ".claude.json");
|
||||
const seed = fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, "utf8")) : {};
|
||||
seed.hasCompletedOnboarding = true;
|
||||
const key = process.env.ANTHROPIC_API_KEY || "";
|
||||
if (key) seed.customApiKeyResponses = { approved: [key.slice(-20)], rejected: [] };
|
||||
fs.writeFileSync(p, JSON.stringify(seed, null, 2));
|
||||
console.log("seeded", p);
|
||||
'
|
||||
uses: ./.github/actions/seed-claude-config
|
||||
with:
|
||||
anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
- name: Register gstack skills for PTY tests
|
||||
run: |
|
||||
set -eu
|
||||
SKILLS_DIR="$HOME/.claude/skills"
|
||||
REPO="$GITHUB_WORKSPACE"
|
||||
mkdir -p "$SKILLS_DIR"
|
||||
ln -snf "$REPO" "$SKILLS_DIR/gstack"
|
||||
for s in office-hours plan-ceo-review plan-eng-review plan-design-review; do
|
||||
rm -rf "${SKILLS_DIR:?}/$s"
|
||||
mkdir -p "$SKILLS_DIR/$s"
|
||||
cp "$REPO/$s/SKILL.md" "$SKILLS_DIR/$s/SKILL.md"
|
||||
cp -R "$REPO/$s/sections" "$SKILLS_DIR/$s/sections"
|
||||
done
|
||||
PROJ_SKILLS="$REPO/.claude/skills"
|
||||
mkdir -p "$PROJ_SKILLS"
|
||||
for s in office-hours plan-ceo-review plan-eng-review plan-design-review; do
|
||||
rm -rf "${PROJ_SKILLS:?}/$s"
|
||||
mkdir -p "$PROJ_SKILLS/$s"
|
||||
cp "$REPO/$s/SKILL.md" "$PROJ_SKILLS/$s/SKILL.md"
|
||||
cp -R "$REPO/$s/sections" "$PROJ_SKILLS/$s/sections"
|
||||
done
|
||||
mkdir -p "$HOME/.gstack"
|
||||
touch "$HOME/.gstack/.activated" \
|
||||
"$HOME/.gstack/.first-loop-tip-shown" \
|
||||
"$HOME/.gstack/.telemetry-prompted" \
|
||||
"$HOME/.gstack/.proactive-prompted" \
|
||||
"$HOME/.gstack/.completeness-intro-seen" \
|
||||
"$HOME/.gstack/.plan-tune-nudge-shown"
|
||||
touch "$SKILLS_DIR/gstack/.feature-prompted-continuous-checkpoint" \
|
||||
"$SKILLS_DIR/gstack/.feature-prompted-model-overlay"
|
||||
uses: ./.github/actions/register-gstack-skills
|
||||
|
||||
- uses: actions/download-artifact@v8
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: paid-plan
|
||||
path: /tmp/paid-plan
|
||||
@@ -228,7 +177,7 @@ jobs:
|
||||
|
||||
- name: Upload slice results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: paid-slice-${{ matrix.slice }}
|
||||
path: /tmp/paid-slice-results
|
||||
@@ -236,7 +185,7 @@ jobs:
|
||||
|
||||
- name: Upload shard logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: paid-slice-${{ matrix.slice }}-logs
|
||||
# The Fix-bun-temp step points TMPDIR at /home/runner/.cache, so the
|
||||
@@ -266,7 +215,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
options: --user runner
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
# Full history: files with SELF-derived selection (the LLM-judge
|
||||
# map, routing) walk git at module load, and selection is
|
||||
@@ -277,21 +226,10 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Fix bun temp
|
||||
run: |
|
||||
mkdir -p /home/runner/.cache/bun
|
||||
{
|
||||
echo "BUN_INSTALL_CACHE_DIR=/home/runner/.cache/bun"
|
||||
echo "BUN_TMPDIR=/home/runner/.cache/bun"
|
||||
echo "TMPDIR=/home/runner/.cache"
|
||||
} >> "$GITHUB_ENV"
|
||||
uses: ./.github/actions/fix-bun-temp
|
||||
|
||||
- name: Restore deps
|
||||
run: |
|
||||
if [ -d /opt/node_modules_cache ] && diff -q /opt/node_modules_cache/.bun.lock bun.lock >/dev/null 2>&1; then
|
||||
cp -r /opt/node_modules_cache node_modules
|
||||
else
|
||||
bun install
|
||||
fi
|
||||
uses: ./.github/actions/restore-deps
|
||||
|
||||
- run: bun run build
|
||||
|
||||
@@ -309,7 +247,7 @@ jobs:
|
||||
|
||||
- name: Upload census results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: gate-census
|
||||
path: /tmp/gate-census-results
|
||||
@@ -328,22 +266,22 @@ jobs:
|
||||
# `gh api /issues` — gated by the issues permission.
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
bun-version: 1.3.13
|
||||
|
||||
- run: bun install --frozen-lockfile
|
||||
|
||||
- uses: actions/download-artifact@v8
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: paid-plan
|
||||
path: /tmp/paid-report
|
||||
|
||||
- uses: actions/download-artifact@v8
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
pattern: paid-slice-[0-9]*
|
||||
path: /tmp/paid-report
|
||||
@@ -354,7 +292,11 @@ jobs:
|
||||
run: |
|
||||
set +e
|
||||
EVALS_TIER=periodic bun run scripts/test-paid-shards.ts --tier periodic --report /tmp/paid-report | tee /tmp/report.txt
|
||||
echo "exit=$?" >> "$GITHUB_OUTPUT"
|
||||
# PIPESTATUS[0], NOT $?: GitHub's default run-step shell is
|
||||
# `bash -e {0}` with NO pipefail, so $? after the pipe is tee's
|
||||
# exit (always 0) — the fail-closed gate was silently fail-open
|
||||
# (caught by the ship review army; the wiring test now pins this).
|
||||
echo "exit=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# A red weekly lane nobody must action is waste — upsert ONE tracking
|
||||
# issue (never a new issue per week) with the reconciliation output, so
|
||||
|
||||
+192
-496
@@ -15,7 +15,6 @@ concurrency:
|
||||
|
||||
env:
|
||||
IMAGE: ghcr.io/${{ github.repository }}/ci
|
||||
EVALS_TIER: gate
|
||||
|
||||
jobs:
|
||||
# Build Docker image with pre-baked toolchain (cached — only rebuilds on Dockerfile/lockfile change)
|
||||
@@ -34,7 +33,7 @@ jobs:
|
||||
outputs:
|
||||
image-tag: ${{ steps.meta.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- id: meta
|
||||
# Key on Dockerfile + lockfile only. package.json is deliberately NOT
|
||||
@@ -44,7 +43,7 @@ jobs:
|
||||
# overwrites /workspace and node_modules comes from the lockfile.
|
||||
run: echo "tag=${{ env.IMAGE }}:${{ hashFiles('.github/docker/Dockerfile.ci', 'bun.lock', 'patches/**') }}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: docker/login-action@v4
|
||||
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -69,10 +68,10 @@ jobs:
|
||||
# Registry cache export needs a docker-container builder — the default
|
||||
# `docker` driver hard-errors on cache-to (first live run of the trio).
|
||||
- if: steps.check.outputs.exists == 'false'
|
||||
uses: docker/setup-buildx-action@v4
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
|
||||
|
||||
- if: steps.check.outputs.exists == 'false'
|
||||
uses: docker/build-push-action@v7
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
|
||||
with:
|
||||
context: .github/docker
|
||||
file: .github/docker/Dockerfile.ci
|
||||
@@ -85,430 +84,27 @@ jobs:
|
||||
${{ steps.meta.outputs.tag }}
|
||||
${{ env.IMAGE }}:latest
|
||||
|
||||
# Fork PRs never receive repository secrets (ANTHROPIC_API_KEY et al), so every
|
||||
# API-calling eval fails at SDK auth before a model runs. Skip deterministically
|
||||
# rather than leaving the outcome to Docker-cache luck: a warm cache let these
|
||||
# run and fail, a cold one made build-image fail its push and the shards skip.
|
||||
# Same-repo PRs, pushes, and workflow_dispatch keep full coverage. Fork work
|
||||
# gets real coverage via a trusted base-repo branch.
|
||||
evals:
|
||||
runs-on: ${{ matrix.suite.runner || 'ubicloud-standard-8' }}
|
||||
needs: build-image
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
# Least privilege for the job that executes PR-authored code with three
|
||||
# provider API keys in env: read-only contents, packages:read for the
|
||||
# container-image pull below. Without this block the job ran on the
|
||||
# repo-default token grant.
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
container:
|
||||
image: ${{ needs.build-image.outputs.image-tag }}
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
options: --user runner
|
||||
timeout-minutes: ${{ matrix.suite.timeout || 25 }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
suite:
|
||||
- name: llm-judge
|
||||
file: test/skill-llm-eval.test.ts
|
||||
- name: e2e-browse
|
||||
file: test/skill-e2e-bws.test.ts
|
||||
runner: ubicloud-standard-8
|
||||
- name: e2e-plan
|
||||
file: test/skill-e2e-plan.test.ts
|
||||
- name: e2e-deploy
|
||||
file: test/skill-e2e-deploy.test.ts
|
||||
- name: e2e-design
|
||||
file: test/skill-e2e-design.test.ts
|
||||
- name: e2e-qa-bugs
|
||||
file: test/skill-e2e-qa-bugs.test.ts
|
||||
- name: e2e-qa-workflow
|
||||
file: test/skill-e2e-qa-workflow.test.ts
|
||||
- name: e2e-review
|
||||
file: test/skill-e2e-review.test.ts
|
||||
- name: e2e-retro
|
||||
file: test/skill-e2e-retro.test.ts
|
||||
- name: e2e-review-attribution
|
||||
file: test/skill-e2e-review-attribution.test.ts
|
||||
- name: e2e-workflow
|
||||
file: test/skill-e2e-workflow.test.ts
|
||||
# Earned its extra attempt with receipts: document-release is a
|
||||
# long multi-step E2E that timed out on attempt 2 under in-shard
|
||||
# concurrency (PR #2593 round 4) while passing other rounds.
|
||||
retries: 2
|
||||
# Rehomed from the deleted pre-split monolith (its filename never
|
||||
# matched the skill-e2e-* glob, so these gate tests silently never
|
||||
# ran). Both files hold gate-tier tests: review/plan-eng coverage
|
||||
# audits and the /ship failure-ownership triage.
|
||||
- name: e2e-coverage-audit
|
||||
file: test/skill-e2e-coverage-audit.test.ts
|
||||
- name: e2e-triage
|
||||
file: test/skill-e2e-triage.test.ts
|
||||
# ship-docsync is whole-file tier-gated (describeE2ETier('gate') keeps
|
||||
# it out of the periodic shard census), so its row MUST set tier: gate
|
||||
# — without it the self-gate skips every test and the job reports a
|
||||
# hollow green (the same silent-skip class as the rehomed monolith
|
||||
# above, one layer deeper). The Run step exports EVALS_TIER from this
|
||||
# property; rows without it keep EVALS_TIER empty (= unset: every
|
||||
# reader is `=== '<tier>'` or truthiness). Enforced by
|
||||
# test/evals-workflow-matrix.test.ts.
|
||||
- name: e2e-ship-docsync
|
||||
file: test/skill-e2e-ship-docsync.test.ts
|
||||
tier: gate
|
||||
# #2733 behavioral proof: the document-release JSON contract survives
|
||||
# a firing AUQ gate inside a spawned-marked subagent. Whole-file
|
||||
# tier-gated like ship-docsync, so the row carries tier: gate.
|
||||
- name: e2e-docsync-spawned
|
||||
file: test/skill-e2e-docsync-spawned.test.ts
|
||||
tier: gate
|
||||
# Consent-gate guardrail for the Third-Party Web Actions contract
|
||||
# (Aside recommended-driver rewrite): hermetic shims, deterministic
|
||||
# grep assertions, gate tier in E2E_TIERS.
|
||||
- name: e2e-third-party-actions
|
||||
file: test/skill-e2e-third-party-actions.test.ts
|
||||
tier: gate
|
||||
- name: e2e-routing
|
||||
file: test/skill-routing-e2e.test.ts
|
||||
# (e2e-codex / e2e-gemini rows deleted: both files are whole-file
|
||||
# periodic-tier, so with no row tier: they ran ZERO tests and
|
||||
# reported green on every PR — ~2 min of runner per PR of pure
|
||||
# false confidence. The periodic lane owns these suites.)
|
||||
# Real-PTY plan-mode smokes. Only the deterministically-reliable ones
|
||||
# are CI-gated: office-hours (asks its mode question first, caught by
|
||||
# the collapsed/bullet prose-AUQ detector) and plan-mode-no-op (no
|
||||
# ask-first dependency). The plan-eng/plan-design plan-mode + floor
|
||||
# smokes are periodic (stochastic ask-first — see touchfiles E2E_TIERS).
|
||||
# Needs the interactive-config seed step below; PTY sessions otherwise
|
||||
# wedge on the fresh-container onboarding/API-key dialog.
|
||||
- name: e2e-pty-plan-smoke
|
||||
file: test/skill-e2e-office-hours-auto-mode.test.ts test/skill-e2e-plan-mode-no-op.test.ts
|
||||
# Both files are whole-file describeE2ETier('gate') — without this
|
||||
# row tier: the job burned ~7 min of setup then skipped every
|
||||
# describe (hollow-green since the files adopted the self-gate).
|
||||
tier: gate
|
||||
timeout: 35
|
||||
# The documented contention-heavy PTY family: ROTATING members
|
||||
# failed attempt 2 in consecutive PR #2593 rounds
|
||||
# (plan-design-review, then plan-eng-review) while the family
|
||||
# passes on branches still running three attempts. Every other
|
||||
# row keeps --retry 1.
|
||||
retries: 2
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# Don't write the token into .git/config — this job runs
|
||||
# PR-authored code; nothing in it pushes.
|
||||
persist-credentials: false
|
||||
|
||||
# Bun creates root-owned temp dirs during Docker build. GH Actions runs as
|
||||
# runner user with HOME=/github/home. Redirect bun's cache to a writable dir.
|
||||
- name: Fix bun temp
|
||||
run: |
|
||||
mkdir -p /home/runner/.cache/bun
|
||||
{
|
||||
echo "BUN_INSTALL_CACHE_DIR=/home/runner/.cache/bun"
|
||||
echo "BUN_TMPDIR=/home/runner/.cache/bun"
|
||||
echo "TMPDIR=/home/runner/.cache"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
# Restore pre-installed node_modules from Docker image via recursive
|
||||
# copy. Symlink (`ln -s`) breaks bun's module resolution because bun
|
||||
# resolves a file's realpath when walking up to find node_modules/<dep>;
|
||||
# from a symlinked path, realpath escapes the workspace and sibling
|
||||
# deps no longer resolve. Hardlink copy (`cp -al`) fails because /opt
|
||||
# and /workspace are on different overlay-fs layers ("Invalid
|
||||
# cross-device link"). Recursive copy works on every layout. Cost:
|
||||
# ~5s for ~200 packages of small JS files vs ~0s for symlink — still
|
||||
# vastly cheaper than rerunning `bun install` (network + resolution).
|
||||
- name: Restore deps
|
||||
run: |
|
||||
if [ -d /opt/node_modules_cache ] && diff -q /opt/node_modules_cache/.bun.lock bun.lock >/dev/null 2>&1; then
|
||||
cp -r /opt/node_modules_cache node_modules
|
||||
else
|
||||
bun install
|
||||
fi
|
||||
|
||||
- run: bun run build
|
||||
|
||||
# Verify Playwright can launch Chromium (fails fast if sandbox/deps are broken)
|
||||
- name: Verify Chromium
|
||||
if: matrix.suite.name == 'e2e-browse'
|
||||
run: |
|
||||
echo "whoami=$(whoami) HOME=$HOME TMPDIR=${TMPDIR:-unset}"
|
||||
touch /tmp/.bun-test && rm /tmp/.bun-test && echo "/tmp writable"
|
||||
bun -e "import {chromium} from 'playwright';const b=await chromium.launch({args:['--no-sandbox']});console.log('Chromium OK');await b.close()"
|
||||
|
||||
# PTY smokes spawn the interactive `claude` TUI. A fresh container has no
|
||||
# ~/.claude.json, so claude wedges on the onboarding + "use detected
|
||||
# ANTHROPIC_API_KEY?" dialog and the spawned session never reaches the
|
||||
# skill. Seed onboarding-complete + the key approval (mirrors what the
|
||||
# hermetic E2E child env seeds). Scoped to this suite; needs its OWN key
|
||||
# env (the secrets block below is on the Run step only).
|
||||
- name: Seed claude interactive config
|
||||
if: matrix.suite.name == 'e2e-pty-plan-smoke'
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
run: |
|
||||
node -e '
|
||||
const fs = require("fs"), os = require("os"), path = require("path");
|
||||
const p = path.join(os.homedir(), ".claude.json");
|
||||
const seed = fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, "utf8")) : {};
|
||||
seed.hasCompletedOnboarding = true;
|
||||
const key = process.env.ANTHROPIC_API_KEY || "";
|
||||
if (key) seed.customApiKeyResponses = { approved: [key.slice(-20)], rejected: [] };
|
||||
fs.writeFileSync(p, JSON.stringify(seed, null, 2));
|
||||
console.log("seeded", p);
|
||||
'
|
||||
|
||||
# PTY smokes drive the interactive `claude` TUI and send /office-hours,
|
||||
# /plan-ceo-review, /plan-eng-review, and /plan-design-review. Claude Code
|
||||
# discovers user-scoped skills from $HOME/.claude/skills/<name>/SKILL.md,
|
||||
# but .claude/skills is gitignored, so a fresh CI checkout has NO registry
|
||||
# — claude prints "Unknown command: /plan-ceo-review". Mirror setup's
|
||||
# --no-prefix registry minimally: a gstack root symlink (resolves the
|
||||
# preamble's absolute ~/.claude/skills/gstack/bin/* and
|
||||
# ~/.claude/skills/gstack/<skill>/sections/* paths) plus a per-skill
|
||||
# top-level dir holding SKILL.md (+ sections) symlinks for the four skills
|
||||
# these tests invoke. No ./setup (it builds binaries, launches Chromium,
|
||||
# installs fonts, reads a /dev/tty prompt) and no binary build (SKILL.md +
|
||||
# bin/ + sections/ are committed). $HOME is /github/home here; the spawned
|
||||
# claude inherits it (this runner adds no HOME/CLAUDE_CONFIG_DIR override,
|
||||
# no hermetic mode) and the Seed step already proved claude reads $HOME.
|
||||
#
|
||||
# KEEP THIS STEP even though seedSkills/hermeticSkillsConfigDir() now
|
||||
# registers skills for hermetic PTY children: that registry is SYMLINKS
|
||||
# into the repo checkout, and this container's cross-mount symlinks
|
||||
# defeat the TUI skill scanner (see the note inside the step below) —
|
||||
# the real-file copies here are what the TUI actually reads. HOME is
|
||||
# also not hermeticized, so the absolute ~/.claude/skills/gstack/...
|
||||
# preamble paths resolve through the gstack root symlink this step makes.
|
||||
- name: Register gstack skills for PTY smoke
|
||||
if: matrix.suite.name == 'e2e-pty-plan-smoke'
|
||||
run: |
|
||||
set -eu
|
||||
SKILLS_DIR="$HOME/.claude/skills"
|
||||
REPO="$GITHUB_WORKSPACE" # /__w/gstack/gstack
|
||||
mkdir -p "$SKILLS_DIR"
|
||||
# The gstack root stays a symlink — the preamble's runtime bash resolves
|
||||
# ~/.claude/skills/gstack/bin/* and ~/.claude/skills/gstack/<skill>/sections/*
|
||||
# through it, and bash follows cross-mount symlinks fine.
|
||||
ln -snf "$REPO" "$SKILLS_DIR/gstack"
|
||||
# But the per-skill SKILL.md the TUI DISCOVERS must be a REAL file on the
|
||||
# same mount as $HOME. claude 2.1.187's interactive-TUI skill scanner does
|
||||
# not follow the /github/home -> /__w cross-mount symlink (proven: `claude
|
||||
# -p` discovered the skill — READY — while the TUI rejected /office-hours
|
||||
# as "Unknown command"; a local macOS repro with the identical symlinked
|
||||
# registry recognized it, isolating the failure to the container's
|
||||
# cross-mount symlink). Copy SKILL.md + sections as real files so the TUI
|
||||
# reads them directly.
|
||||
for s in office-hours plan-ceo-review plan-eng-review plan-design-review; do
|
||||
rm -rf "${SKILLS_DIR:?}/$s"
|
||||
mkdir -p "$SKILLS_DIR/$s"
|
||||
cp "$REPO/$s/SKILL.md" "$SKILLS_DIR/$s/SKILL.md"
|
||||
cp -R "$REPO/$s/sections" "$SKILLS_DIR/$s/sections"
|
||||
done
|
||||
# Also register PROJECT-scoped (cwd) skills. claude's interactive TUI
|
||||
# surfaces /slash commands from <cwd>/.claude/skills, and the smokes run
|
||||
# with cwd=$REPO whose .claude/skills is gitignored (absent on a fresh CI
|
||||
# checkout) — the user-dir registration above feeds `claude -p` but the
|
||||
# TUI looks here. No gstack symlink in the project dir: it would point at
|
||||
# its own parent ($REPO). Runtime preamble paths use the user-dir
|
||||
# ~/.claude/skills/gstack symlink above.
|
||||
PROJ_SKILLS="$REPO/.claude/skills"
|
||||
mkdir -p "$PROJ_SKILLS"
|
||||
for s in office-hours plan-ceo-review plan-eng-review plan-design-review; do
|
||||
rm -rf "${PROJ_SKILLS:?}/$s"
|
||||
mkdir -p "$PROJ_SKILLS/$s"
|
||||
cp "$REPO/$s/SKILL.md" "$PROJ_SKILLS/$s/SKILL.md"
|
||||
cp -R "$REPO/$s/sections" "$PROJ_SKILLS/$s/sections"
|
||||
done
|
||||
# Pre-seed every ONE-TIME preamble marker so no PTY child ever takes a
|
||||
# first-run branch mid-test. On a fresh runner these are all missing, so
|
||||
# each smoke's preamble fires feature discovery / telemetry / lake-intro
|
||||
# prompts before the behavior under test — and touching the
|
||||
# feature-discovery marker under ~/.claude/skills/gstack/ trips Claude
|
||||
# Code's sensitive-file permission prompt, stalling the run before the
|
||||
# scope gate renders (the documented intermittent
|
||||
# scope-gate-question-NOT-observed failure: outcome=asked was the
|
||||
# permission dialog, not the gate). Dev machines never hit this because
|
||||
# the operator's markers already exist; CI must seed them explicitly.
|
||||
mkdir -p "$HOME/.gstack"
|
||||
touch "$HOME/.gstack/.activated" \
|
||||
"$HOME/.gstack/.first-loop-tip-shown" \
|
||||
"$HOME/.gstack/.telemetry-prompted" \
|
||||
"$HOME/.gstack/.proactive-prompted" \
|
||||
"$HOME/.gstack/.completeness-intro-seen" \
|
||||
"$HOME/.gstack/.plan-tune-nudge-shown"
|
||||
# These two resolve through the gstack root symlink into $REPO —
|
||||
# untracked scratch in the CI checkout, exactly where the preamble looks.
|
||||
touch "$SKILLS_DIR/gstack/.feature-prompted-continuous-checkpoint" \
|
||||
"$SKILLS_DIR/gstack/.feature-prompted-model-overlay"
|
||||
echo "--- registry under $SKILLS_DIR ---"
|
||||
ls -la "$SKILLS_DIR/gstack" "$SKILLS_DIR/office-hours" "$SKILLS_DIR/plan-ceo-review"
|
||||
# Fail fast if any committed target moved/renamed — a dangling symlink
|
||||
# would otherwise resurface as a silent "Unknown command" + 35-min timeout.
|
||||
for f in \
|
||||
"$SKILLS_DIR/office-hours/SKILL.md" \
|
||||
"$SKILLS_DIR/plan-ceo-review/SKILL.md" \
|
||||
"$SKILLS_DIR/plan-eng-review/SKILL.md" \
|
||||
"$SKILLS_DIR/plan-design-review/SKILL.md" \
|
||||
"$SKILLS_DIR/gstack/bin/gstack-update-check" \
|
||||
"$SKILLS_DIR/gstack/office-hours/sections/design-and-handoff.md" \
|
||||
"$SKILLS_DIR/gstack/plan-ceo-review/sections/review-sections.md" \
|
||||
"$SKILLS_DIR/gstack/plan-eng-review/sections/review-sections.md" \
|
||||
"$SKILLS_DIR/gstack/plan-design-review/sections/review-sections.md"; do
|
||||
if [ ! -e "$f" ]; then
|
||||
echo "ERROR: skill-registry target missing (symlink dangles): $f" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
for s in office-hours plan-ceo-review plan-eng-review plan-design-review; do
|
||||
grep -m1 "^name: $s\$" "$SKILLS_DIR/$s/SKILL.md" >/dev/null \
|
||||
|| { echo "ERROR: $s SKILL.md missing 'name: $s' frontmatter" >&2; exit 1; }
|
||||
done
|
||||
echo "skill registry OK"
|
||||
|
||||
- name: Run ${{ matrix.suite.name }}
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
EVALS_CONCURRENCY: "40"
|
||||
PLAYWRIGHT_BROWSERS_PATH: /opt/playwright-browsers
|
||||
# Per-row tier activation for whole-file-gated suites. Empty when the
|
||||
# row declares no tier — every EVALS_TIER reader treats empty as unset
|
||||
# (`=== '<tier>'` comparisons and the truthiness check in
|
||||
# test/helpers/e2e-helpers.ts:70), so untiered rows are byte-for-byte
|
||||
# unaffected.
|
||||
EVALS_TIER: ${{ matrix.suite.tier || '' }}
|
||||
run: EVALS=1 bun test --retry ${{ matrix.suite.retries || 1 }} --concurrent --max-concurrency 40 ${{ matrix.suite.file }}
|
||||
|
||||
- name: Upload eval results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: eval-${{ matrix.suite.name }}
|
||||
path: ~/.gstack-dev/evals/*.json
|
||||
retention-days: 90
|
||||
|
||||
report:
|
||||
runs-on: ubicloud-standard-2
|
||||
needs: evals
|
||||
if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
# The comment upsert below calls the REST `/issues/{n}/comments` endpoints
|
||||
# (gh api ... issues/comments). With GITHUB_TOKEN those are gated by the
|
||||
# `issues` permission, not `pull-requests` — without it the GET returns 401
|
||||
# on every PR that produces eval artifacts (PRs with no artifacts exit
|
||||
# early and never hit it, which is why this stayed hidden). See #1802 CI fix.
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Download all eval artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: eval-*
|
||||
path: /tmp/eval-results
|
||||
merge-multiple: true
|
||||
|
||||
- name: Post PR comment
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# shellcheck disable=SC2086,SC2059
|
||||
RESULTS=$(find /tmp/eval-results -name '*.json' 2>/dev/null | sort)
|
||||
if [ -z "$RESULTS" ]; then
|
||||
echo "No eval results found"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
TOTAL=0; PASSED=0; FAILED=0; COST="0"
|
||||
SUITE_LINES=""
|
||||
for f in $RESULTS; do
|
||||
if ! jq -e '.total_tests' "$f" >/dev/null 2>&1; then
|
||||
echo "Skipping malformed JSON: $f"
|
||||
continue
|
||||
fi
|
||||
T=$(jq -r '.total_tests // 0' "$f")
|
||||
P=$(jq -r '.passed // 0' "$f")
|
||||
F=$(jq -r '.failed // 0' "$f")
|
||||
C=$(jq -r '.total_cost_usd // 0' "$f")
|
||||
TIER=$(jq -r '.tier // "unknown"' "$f")
|
||||
[ "$T" -eq 0 ] && continue
|
||||
TOTAL=$((TOTAL + T))
|
||||
PASSED=$((PASSED + P))
|
||||
FAILED=$((FAILED + F))
|
||||
COST=$(echo "$COST + $C" | bc)
|
||||
STATUS_ICON="✅"
|
||||
[ "$F" -gt 0 ] && STATUS_ICON="❌"
|
||||
SUITE_LINES="${SUITE_LINES}| ${TIER} | ${P}/${T} | ${STATUS_ICON} | \$${C} |\n"
|
||||
done
|
||||
|
||||
STATUS="✅ PASS"
|
||||
[ "$FAILED" -gt 0 ] && STATUS="❌ FAIL"
|
||||
|
||||
BODY="## E2E Evals: ${STATUS}
|
||||
|
||||
**${PASSED}/${TOTAL}** tests passed | **\$${COST}** total cost
|
||||
|
||||
| Suite | Result | Status | Cost |
|
||||
|-------|--------|--------|------|
|
||||
$(echo -e "$SUITE_LINES")
|
||||
|
||||
---
|
||||
*ubicloud-standard-8 runners (Docker: pre-baked toolchain + deps) | wall clock ≈ slowest suite*"
|
||||
|
||||
if [ "$FAILED" -gt 0 ]; then
|
||||
FAILURES=""
|
||||
for f in $RESULTS; do
|
||||
if ! jq -e '.failed' "$f" >/dev/null 2>&1; then continue; fi
|
||||
F=$(jq -r '.failed // 0' "$f")
|
||||
[ "$F" -eq 0 ] && continue
|
||||
FAILS=$(jq -r '.tests[] | select(.passed == false) | "- ❌ \(.name): \(.exit_reason // "unknown")"' "$f" 2>/dev/null || echo "- ⚠️ $(basename "$f"): parse error")
|
||||
FAILURES="${FAILURES}${FAILS}\n"
|
||||
done
|
||||
BODY="${BODY}
|
||||
|
||||
### Failures
|
||||
$(echo -e "$FAILURES")"
|
||||
fi
|
||||
|
||||
# Update existing comment or create new one
|
||||
COMMENT_ID=$(gh api repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments \
|
||||
--jq '.[] | select(.body | startswith("## E2E Evals")) | .id' | tail -1)
|
||||
|
||||
if [ -n "$COMMENT_ID" ]; then
|
||||
gh api "repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" \
|
||||
-X PATCH -f body="$BODY"
|
||||
else
|
||||
gh pr comment "${{ github.event.pull_request.number }}" --body "$BODY"
|
||||
fi
|
||||
|
||||
# ── Sliced lane (paid-CI re-platform, parity phase) ─────────────────────────
|
||||
# ── Sliced lane (the ONLY paid lane; legacy 17-row matrix deleted) ──────────
|
||||
# One PLANNER computes diff selection + the slice plan ONCE (killing
|
||||
# per-slice selector divergence); K executors consume the manifest; the
|
||||
# report reconciles results against it FAIL-CLOSED (a slice whose artifact
|
||||
# never landed is a failure, a planned shard nobody reported is a failure —
|
||||
# hollow lanes cannot aggregate green). Runs AFTER the matrix (`needs:
|
||||
# evals`) so provider concurrency never doubles while both lanes coexist;
|
||||
# once parity is demonstrated the matrix + its ratchets are deleted and this
|
||||
# lane loses the needs edge. Engine: scripts/test-paid-shards.ts — the same
|
||||
# runner local eval:bg:gate uses, so CI and local share one selection engine.
|
||||
# hollow lanes cannot aggregate green). Engine: scripts/test-paid-shards.ts —
|
||||
# the same runner local eval:bg:gate uses, so CI and local share one
|
||||
# selection engine, and every gate-tier file is in the census by
|
||||
# construction (no hand-enumerated rows to drift). The legacy matrix ran
|
||||
# 18 enumerated files for 22.6 min/$21 per PR serialized AHEAD of this
|
||||
# lane's 49-file diff-selected census; parity was demonstrated (sliced
|
||||
# census ⊇ matrix files) and the matrix deleted — one revert restores it.
|
||||
#
|
||||
# Fork PRs never receive repository secrets (ANTHROPIC_API_KEY et al), so
|
||||
# every API-calling eval fails at SDK auth before a model runs. Skip
|
||||
# deterministically; fork work gets real coverage via a trusted base-repo
|
||||
# branch (see CLAUDE.md's garrytan-agents workflow).
|
||||
plan-slices:
|
||||
runs-on: ubicloud-standard-8
|
||||
needs: [build-image, evals]
|
||||
if: always() && needs.build-image.result == 'success' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
|
||||
needs: build-image
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -520,7 +116,7 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
options: --user runner
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
# The planner is the ONE place that needs history: diff selection
|
||||
# resolves a merge-base. Executors run from the manifest and stay
|
||||
@@ -530,19 +126,14 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Restore deps
|
||||
run: |
|
||||
if [ -d /opt/node_modules_cache ] && diff -q /opt/node_modules_cache/.bun.lock bun.lock >/dev/null 2>&1; then
|
||||
cp -r /opt/node_modules_cache node_modules
|
||||
else
|
||||
bun install
|
||||
fi
|
||||
uses: ./.github/actions/restore-deps
|
||||
|
||||
- name: Emit run manifest
|
||||
env:
|
||||
EVALS_ALL: ${{ (github.event_name == 'workflow_dispatch' && inputs.evals_all) && '1' || '' }}
|
||||
run: EVALS_TIER=gate bun run scripts/test-paid-shards.ts --tier gate --emit-plan /tmp/paid-plan/manifest.json --slices 6
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: paid-plan
|
||||
path: /tmp/paid-plan/manifest.json
|
||||
@@ -572,7 +163,7 @@ jobs:
|
||||
matrix:
|
||||
slice: [1, 2, 3, 4, 5, 6]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
# Full history: files with SELF-derived selection (the LLM-judge
|
||||
# map, routing) walk git at module load, and selection is
|
||||
@@ -583,74 +174,28 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Fix bun temp
|
||||
run: |
|
||||
mkdir -p /home/runner/.cache/bun
|
||||
{
|
||||
echo "BUN_INSTALL_CACHE_DIR=/home/runner/.cache/bun"
|
||||
echo "BUN_TMPDIR=/home/runner/.cache/bun"
|
||||
echo "TMPDIR=/home/runner/.cache"
|
||||
} >> "$GITHUB_ENV"
|
||||
uses: ./.github/actions/fix-bun-temp
|
||||
|
||||
- name: Restore deps
|
||||
run: |
|
||||
if [ -d /opt/node_modules_cache ] && diff -q /opt/node_modules_cache/.bun.lock bun.lock >/dev/null 2>&1; then
|
||||
cp -r /opt/node_modules_cache node_modules
|
||||
else
|
||||
bun install
|
||||
fi
|
||||
uses: ./.github/actions/restore-deps
|
||||
|
||||
- run: bun run build
|
||||
|
||||
# Any slice can host a PTY smoke, so the seed/registration steps run
|
||||
# UNCONDITIONALLY (both are idempotent) — the old matrix keyed them on
|
||||
# matrix.suite.name, which a sliced lane cannot do.
|
||||
# matrix.suite.name, which a sliced lane cannot do. The register
|
||||
# composite carries the fail-fast dangling-symlink/frontmatter
|
||||
# verification loop, so a moved skill target fails HERE in seconds,
|
||||
# not as a wedged PTY session at the shard wall.
|
||||
- name: Seed claude interactive config
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
run: |
|
||||
node -e '
|
||||
const fs = require("fs"), os = require("os"), path = require("path");
|
||||
const p = path.join(os.homedir(), ".claude.json");
|
||||
const seed = fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, "utf8")) : {};
|
||||
seed.hasCompletedOnboarding = true;
|
||||
const key = process.env.ANTHROPIC_API_KEY || "";
|
||||
if (key) seed.customApiKeyResponses = { approved: [key.slice(-20)], rejected: [] };
|
||||
fs.writeFileSync(p, JSON.stringify(seed, null, 2));
|
||||
console.log("seeded", p);
|
||||
'
|
||||
uses: ./.github/actions/seed-claude-config
|
||||
with:
|
||||
anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
- name: Register gstack skills for PTY smokes
|
||||
run: |
|
||||
set -eu
|
||||
SKILLS_DIR="$HOME/.claude/skills"
|
||||
REPO="$GITHUB_WORKSPACE"
|
||||
mkdir -p "$SKILLS_DIR"
|
||||
ln -snf "$REPO" "$SKILLS_DIR/gstack"
|
||||
for s in office-hours plan-ceo-review plan-eng-review plan-design-review; do
|
||||
rm -rf "${SKILLS_DIR:?}/$s"
|
||||
mkdir -p "$SKILLS_DIR/$s"
|
||||
cp "$REPO/$s/SKILL.md" "$SKILLS_DIR/$s/SKILL.md"
|
||||
cp -R "$REPO/$s/sections" "$SKILLS_DIR/$s/sections"
|
||||
done
|
||||
PROJ_SKILLS="$REPO/.claude/skills"
|
||||
mkdir -p "$PROJ_SKILLS"
|
||||
for s in office-hours plan-ceo-review plan-eng-review plan-design-review; do
|
||||
rm -rf "${PROJ_SKILLS:?}/$s"
|
||||
mkdir -p "$PROJ_SKILLS/$s"
|
||||
cp "$REPO/$s/SKILL.md" "$PROJ_SKILLS/$s/SKILL.md"
|
||||
cp -R "$REPO/$s/sections" "$PROJ_SKILLS/$s/sections"
|
||||
done
|
||||
mkdir -p "$HOME/.gstack"
|
||||
touch "$HOME/.gstack/.activated" \
|
||||
"$HOME/.gstack/.first-loop-tip-shown" \
|
||||
"$HOME/.gstack/.telemetry-prompted" \
|
||||
"$HOME/.gstack/.proactive-prompted" \
|
||||
"$HOME/.gstack/.completeness-intro-seen" \
|
||||
"$HOME/.gstack/.plan-tune-nudge-shown"
|
||||
touch "$SKILLS_DIR/gstack/.feature-prompted-continuous-checkpoint" \
|
||||
"$SKILLS_DIR/gstack/.feature-prompted-model-overlay"
|
||||
uses: ./.github/actions/register-gstack-skills
|
||||
|
||||
- uses: actions/download-artifact@v8
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: paid-plan
|
||||
path: /tmp/paid-plan
|
||||
@@ -668,7 +213,7 @@ jobs:
|
||||
|
||||
- name: Upload slice results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: paid-slice-${{ matrix.slice }}
|
||||
path: /tmp/paid-slice-results
|
||||
@@ -678,7 +223,7 @@ jobs:
|
||||
# later needs more than a summary line.
|
||||
- name: Upload shard logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: paid-slice-${{ matrix.slice }}-logs
|
||||
# The Fix-bun-temp step points TMPDIR at /home/runner/.cache, so the
|
||||
@@ -697,29 +242,180 @@ jobs:
|
||||
# missing slice artifact reading as green is the class this lane kills.
|
||||
if: always() && needs.plan-slices.result == 'success'
|
||||
timeout-minutes: 5
|
||||
# contents:read ONLY — this job executes PR-authored code (bun install
|
||||
# lifecycle scripts + the reconcile runner from the PR checkout), so it
|
||||
# must never hold a write-scoped token. The PR comment lives in the
|
||||
# separate slices-comment job below, which runs NO repo code: a
|
||||
# $GITHUB_ENV/BASH_ENV persistence trick is job-scoped, so the split is
|
||||
# the trust boundary (codex adversarial finding, 2026-08-31 — the old
|
||||
# matrix-era report job had this separation and the consolidation had
|
||||
# regressed it).
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
reconcile-exit: ${{ steps.reconcile.outputs.exit }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
|
||||
with:
|
||||
bun-version: 1.3.13
|
||||
|
||||
- run: bun install --frozen-lockfile
|
||||
|
||||
- uses: actions/download-artifact@v8
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: paid-plan
|
||||
path: /tmp/paid-report
|
||||
|
||||
- uses: actions/download-artifact@v8
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
pattern: paid-slice-[0-9]*
|
||||
path: /tmp/paid-report
|
||||
merge-multiple: true
|
||||
|
||||
- name: Reconcile slices against the manifest (fail-closed)
|
||||
run: EVALS_TIER=gate bun run scripts/test-paid-shards.ts --tier gate --report /tmp/paid-report
|
||||
id: reconcile
|
||||
run: |
|
||||
set +e
|
||||
EVALS_TIER=gate bun run scripts/test-paid-shards.ts --tier gate --report /tmp/paid-report | tee /tmp/report.txt
|
||||
# PIPESTATUS[0], NOT $?: GitHub's default run-step shell is
|
||||
# `bash -e {0}` with NO pipefail, so $? after the pipe is tee's
|
||||
# exit (always 0) — the fail-closed gate was silently fail-open
|
||||
# (caught by the ship review army; the wiring test now pins this).
|
||||
echo "exit=${PIPESTATUS[0]}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload reconciliation output for the comment job
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: report-verdict
|
||||
path: /tmp/report.txt
|
||||
if-no-files-found: ignore
|
||||
retention-days: 30
|
||||
|
||||
- name: Fail the workflow when reconciliation failed
|
||||
if: steps.reconcile.outputs.exit != '0'
|
||||
run: exit 1
|
||||
|
||||
# PR comment in its OWN job with the write token and ZERO repo code: no
|
||||
# checkout, no bun install — only downloaded artifacts, jq, and gh. See the
|
||||
# trust-boundary note on slices-report.
|
||||
slices-comment:
|
||||
runs-on: ubicloud-standard-2
|
||||
needs: slices-report
|
||||
if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && needs.slices-report.result != 'skipped'
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
pull-requests: write
|
||||
# The comment upsert calls the REST `/issues/{n}/comments` endpoints
|
||||
# (gh api ... issues/comments). With GITHUB_TOKEN those are gated by the
|
||||
# `issues` permission, not `pull-requests` (#1802 CI fix).
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
pattern: paid-slice-[0-9]*
|
||||
path: /tmp/paid-report
|
||||
merge-multiple: true
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: report-verdict
|
||||
path: /tmp/verdict
|
||||
continue-on-error: true
|
||||
|
||||
# Sourced from the slice artifacts' eval-store JSONs. Keeps the
|
||||
# "## E2E Evals" marker so the upsert keeps updating the same comment.
|
||||
# Runs even when reconciliation failed — a red lane on the PR is the point.
|
||||
- name: Post PR comment
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RECONCILE_EXIT: ${{ needs.slices-report.outputs.reconcile-exit }}
|
||||
run: |
|
||||
# shellcheck disable=SC2086,SC2059
|
||||
RESULTS=$(find /tmp/paid-report -name '*.json' ! -name 'manifest.json' ! -name 'slice-*.json' ! -name '_partial*' 2>/dev/null | sort)
|
||||
TOTAL=0; PASSED=0; FAILED=0; FLAKY=0; COST="0"
|
||||
SUITE_LINES=""
|
||||
for f in $RESULTS; do
|
||||
if ! jq -e '.total_tests' "$f" >/dev/null 2>&1; then
|
||||
echo "Skipping malformed JSON: $f"
|
||||
continue
|
||||
fi
|
||||
# FINAL-attempt accounting: eval-store keeps EVERY retry attempt
|
||||
# as its own record (that's the flake telemetry), so counting raw
|
||||
# records marks a pass-on-retry as a failure and inflates totals.
|
||||
# Group by test name, judge the LAST record — flaky passes render
|
||||
# as the ⚠ line, never as ❌ (WS1 policy: recorded, not blocking).
|
||||
# Guarded: a file with total_tests but a null/non-array `tests`
|
||||
# passes the -e probe, the group_by then fails, and an empty $T
|
||||
# would abort the whole step under bash -e ([ "" -eq 0 ] is an
|
||||
# error) — killing the comment on exactly the corrupted-artifact
|
||||
# runs where the red evidence matters (claude adversarial).
|
||||
STATS=$(jq -r '[.tests | group_by(.name)[] | last] as $final | "\($final | length) \([$final[] | select(.passed)] | length) \([$final[] | select(.passed | not)] | length) \(.flaky_retries // [] | length)"' "$f" 2>/dev/null) || { echo "Skipping malformed tests[] in: $f"; continue; }
|
||||
read -r T P F FL <<< "$STATS"
|
||||
[ -z "$T" ] && { echo "Skipping malformed tests[] in: $f"; continue; }
|
||||
C=$(jq -r '.total_cost_usd // 0' "$f")
|
||||
TIER=$(jq -r '.tier // "unknown"' "$f")
|
||||
SHARD=$(jq -r '.shard // "-"' "$f")
|
||||
[ "$T" -eq 0 ] && continue
|
||||
TOTAL=$((TOTAL + T))
|
||||
PASSED=$((PASSED + P))
|
||||
FAILED=$((FAILED + F))
|
||||
FLAKY=$((FLAKY + FL))
|
||||
COST=$(echo "$COST + $C" | bc)
|
||||
STATUS_ICON="✅"
|
||||
[ "$F" -gt 0 ] && STATUS_ICON="❌"
|
||||
[ "$F" -eq 0 ] && [ "$FL" -gt 0 ] && STATUS_ICON="✅⚠"
|
||||
SUITE_LINES="${SUITE_LINES}| ${TIER}/${SHARD} | ${P}/${T} | ${STATUS_ICON} | \$${C} |\n"
|
||||
done
|
||||
|
||||
STATUS="✅ PASS"
|
||||
if [ "${RECONCILE_EXIT:-1}" != "0" ] || [ "$FAILED" -gt 0 ]; then STATUS="❌ FAIL"; fi
|
||||
|
||||
BODY="## E2E Evals: ${STATUS}
|
||||
|
||||
**${PASSED}/${TOTAL}** tests passed | **\$${COST}** total cost | reconcile exit: ${RECONCILE_EXIT:-missing}$([ "$FLAKY" -gt 0 ] && printf ' | ⚠ %s flaky pass(es) — recorded, not blocking' "$FLAKY")
|
||||
|
||||
| Shard | Result | Status | Cost |
|
||||
|-------|--------|--------|------|
|
||||
$(echo -e "$SUITE_LINES")
|
||||
|
||||
<details><summary>Fail-closed reconciliation</summary>
|
||||
|
||||
\`\`\`
|
||||
$(tail -c 4000 /tmp/verdict/report.txt 2>/dev/null || echo '(no reconciliation output)')
|
||||
\`\`\`
|
||||
</details>
|
||||
|
||||
---
|
||||
*Sliced lane: diff-selected gate census via scripts/test-paid-shards.ts (planner → 6 executors → fail-closed report)*"
|
||||
|
||||
if [ "$FAILED" -gt 0 ]; then
|
||||
FAILURES=""
|
||||
for f in $RESULTS; do
|
||||
if ! jq -e '.failed' "$f" >/dev/null 2>&1; then continue; fi
|
||||
FAILS=$(jq -r '[.tests | group_by(.name)[] | last | select(.passed == false)][] | "- ❌ \(.name): \(.exit_reason // "unknown")"' "$f" 2>/dev/null || echo "- ⚠️ parse error")
|
||||
FAILURES="${FAILURES}${FAILS}\n"
|
||||
done
|
||||
BODY="${BODY}
|
||||
|
||||
### Failures
|
||||
$(echo -e "$FAILURES")"
|
||||
fi
|
||||
|
||||
COMMENT_ID=$(gh api repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments \
|
||||
--jq '.[] | select(.body | startswith("## E2E Evals")) | .id' | tail -1)
|
||||
|
||||
if [ -n "$COMMENT_ID" ]; then
|
||||
gh api "repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" \
|
||||
-X PATCH -f body="$BODY"
|
||||
else
|
||||
# REST, not gh's pr-comment subcommand: this job runs with NO
|
||||
# checkout (the token/exec split), and that subcommand resolves
|
||||
# the repo FROM git — it dies with "not a git repository" here.
|
||||
gh api "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments" \
|
||||
-X POST -f body="$BODY"
|
||||
fi
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
runs-on: ubicloud-standard-8
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -129,6 +129,27 @@ jobs:
|
||||
run: xvfb-run -a bun run test:free
|
||||
env:
|
||||
GSTACK_EXPECT_BINARIES: "1"
|
||||
# WS1 flake telemetry: a single timing flake must not red the only
|
||||
# required lane — the runner's attribution-gated retry pass (cap 5,
|
||||
# truncation veto) re-runs failing files once, serially, and a
|
||||
# clean retry downgrades to a LOUD flaky-pass. Every flaky-pass is
|
||||
# appended to the ledger (single writer: the parent runner) and
|
||||
# uploaded below, so repeat offenders are an enumerable series —
|
||||
# recorded and ranked, never masked. Pinned by
|
||||
# test/free-tests-workflow-wiring.test.ts.
|
||||
GSTACK_FREE_RETRY_FLAKY: "1"
|
||||
GSTACK_FLAKE_LEDGER: ${{ runner.temp }}/flake-ledger.jsonl
|
||||
|
||||
# Uploaded unconditionally (not just on failure): a flaky-pass run is
|
||||
# GREEN — that's the point — so its evidence must survive green runs.
|
||||
- name: Upload flake ledger
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: flake-ledger
|
||||
path: ${{ runner.temp }}/flake-ledger.jsonl
|
||||
if-no-files-found: ignore
|
||||
retention-days: 90
|
||||
|
||||
# The runner streams the full child output to per-run logs under the OS
|
||||
# tmpdir and prints only the quiet contract to the console. Without this
|
||||
@@ -137,7 +158,7 @@ jobs:
|
||||
# need a local re-run, which fork contributors can't do on this image.
|
||||
- name: Upload shard logs on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: free-test-shard-logs
|
||||
path: /tmp/gstack-free-test-*.log
|
||||
|
||||
Reference in New Issue
Block a user