Repository navigation
fix(admin): console and panel sentences follow the venue a run used (#712) #1458
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # REQ-UPSTREAM-CONTRACT-TESTS — the pi upgrade gate. | |
| # | |
| # Asserts the assumptions pi-dispatch pins about pi, and fails the build when one stops holding. | |
| # CONST-PI-VERSION-PINNED makes an upgrade an explicit commit that edits a version string, so this | |
| # workflow is what turns that commit into a gate rather than a hope. | |
| # | |
| # Every assertion below covers a failure that is SILENT. A crash reports itself and needs no test; | |
| # these do not. Each maps to a point where a claim about pi was verified, believed, and later refuted: | |
| # - "appendSystemPrompt composes with file discovery" -> it REPLACES it, via a `??` | |
| # - "createAgentSession reloads the loader you pass" -> only one it built itself | |
| # - "-nc is a flag you remember" -> it is an object you forget to build | |
| # - "pi never throws" -> its own JSDoc says otherwise | |
| # Each was found by reading source, not by running code. These tests are what catch the next one. | |
| name: pi upgrade check | |
| on: | |
| # MAIN ONLY. Without a `branches:` key this fired on every branch, so a commit on a PR branch ran the | |
| # whole suite TWICE -- once here, once as `pull_request` -- and both runs emitted the same five required | |
| # context names for the same sha. That is exactly the failure the comment below rejects a companion | |
| # workflow for ("two check runs then share one context name -- a green stand-in that can mask a red real | |
| # run"), reached through a different door. A PR branch now gets one run, from `pull_request`; main gets | |
| # one, from here. The cost is that pushing a branch with no PR open runs nothing, which is the intended | |
| # trade: checks report when there is something for them to gate. | |
| push: | |
| branches: [main] | |
| paths: | |
| - "image/**" | |
| - "worker/**" | |
| - "receiver/**" | |
| - "admin/**" | |
| - "guardrails/**" | |
| - "package.json" | |
| - "pi-packages.example.json" | |
| - "triggers.example.json" | |
| - ".github/workflows/pi-upgrade-check.yml" | |
| - ".github/scripts/admin-pi-canary.mjs" | |
| - ".github/scripts/host-pi-canary.mjs" | |
| - ".github/scripts/job-user-e2e.mjs" | |
| - ".github/scripts/fake-key-smoke.mjs" | |
| - ".github/scripts/pi-pin-check.mjs" | |
| - ".github/scripts/pi-derived.mjs" | |
| - ".github/scripts/pi-bump.mjs" | |
| - ".github/workflows/pi-bump.yml" | |
| # DELIBERATELY UNFILTERED, unlike the push trigger above. Five of the jobs below are REQUIRED status | |
| # checks on main (issue #105), and a required check that never reports blocks the merge FOREVER -- a | |
| # docs-only PR under the old path filter reported nothing at all (PR #106: "no checks reported") and | |
| # would now be unmergeable. Re-adding a `paths:` here re-opens that deadlock. | |
| # | |
| # The rejected alternative was a companion workflow carrying the complementary `paths-ignore:` list and | |
| # emitting same-named stand-in jobs: a PR touching BOTH docs/ and worker/ fires both workflows, because | |
| # paths-ignore runs whenever ANY changed file falls outside its list, and two check runs then share one | |
| # context name -- a green stand-in that can mask a red real run. The whole suite is ~90s; buying that | |
| # back is not worth a mechanism that can hide a failure. | |
| pull_request: | |
| schedule: | |
| # Weekly: pi ships breaking changes between minors and its HEAD moved within 24h of this | |
| # project's design being written. A pin that is never exercised rots silently. | |
| - cron: "0 6 * * 1" | |
| workflow_dispatch: | |
| # A BARE LITERAL GROUP WOULD BE WRONG HERE, which is why this departs from the four publisher workflows | |
| # (image, receiver-image, release, repo-release). Those are `push: branches: [main]` only, so `group: image` | |
| # names one queue that genuinely should be serial. This workflow runs on `pull_request`, and a literal | |
| # would put EVERY open PR in one group -- each new PR cancelling the last one's checks. | |
| # | |
| # `cancel-in-progress` is safe here and is not safe there: a superseded run of this workflow is one whose | |
| # commit nobody is merging any more, while a superseded run of `release` is a publish halfway through. | |
| # Ten pushes to a branch used to leave ten sets of runs queued against each other, and the newest commit -- | |
| # the only one that matters -- waited behind its own predecessors. | |
| # Read-only for every job: none of them writes, and a pi bump's pull request runs this workflow on pi code nobody has | |
| # reviewed yet (issue #587). A job that needs more names it on itself. | |
| permissions: | |
| contents: read | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| version-pin: | |
| name: pins are exact (CONST-PI-VERSION-PINNED) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: No floating ranges on pi packages | |
| run: | | |
| # A floating range turns a silent upstream minor into every queued job becoming a | |
| # no-op with no signal, because the queue still reports success. | |
| if grep -rnE '@earendil-works/pi-[a-z-]+@[\^~]|"@earendil-works/pi-[a-z-]+":[[:space:]]*"[\^~]' image/ package.json; then | |
| echo "::error::Floating range on a pi package. CONST-PI-VERSION-PINNED requires an exact pin." | |
| exit 1 | |
| fi | |
| echo "OK: pi pinned exactly" | |
| - name: The image's pi-version LABEL matches the runner's pin | |
| run: | | |
| # The worker reads this label to decide whether a persisted transcript was written by the same | |
| # pi (REQ-RESUMABLE-SESSION). If it drifts from the runner's actual pin the failure is silent | |
| # in the worst direction: the label says the schema matches, the schema does not, and a resumed | |
| # job hands pi tool-call arguments it no longer accepts. A stale label is worse than no label, | |
| # because no label means "never resume" and a wrong one means "resume anyway". | |
| pinned=$(node -p "require('./image/runner/package.json').dependencies['@earendil-works/pi-coding-agent']") | |
| labelled=$(grep -oE 'ARG PI_VERSION=[0-9][^[:space:]]*' image/Dockerfile | cut -d= -f2) | |
| if [ "$pinned" != "$labelled" ]; then | |
| echo "::error::image/Dockerfile PI_VERSION=$labelled but image/runner/package.json pins $pinned" | |
| exit 1 | |
| fi | |
| echo "OK: image label and runner pin agree ($pinned)" | |
| - name: Every pi package is the runner's pin, in the overrides and the lockfile | |
| run: | | |
| # Issue #587. pi 1.0.1 dropped its npm-shrinkwrap.json and depends on its sibling packages by a | |
| # range (^1.0.3), so a lockfile regenerated on another day can resolve pi-tui or pi-agent-core to | |
| # a release nobody verified while every package.json still shows an exact pin. The root | |
| # package.json's overrides pin every @earendil-works package to the runner's pin; this holds the | |
| # overrides, every pi entry in package-lock.json and the hand-written pins (worker, admin, | |
| # SUPPORTED_PI_VERSION, the Dockerfile's PI_VERSION) to that one version. Its rule is tested in | |
| # worker/test/pi-pin-check.test.mjs. | |
| node .github/scripts/pi-pin-check.mjs | |
| - name: Base image is pinned by digest | |
| run: | | |
| # Same reasoning, different vendor. A floating base tag is the identical silent break. | |
| if ! grep -qE '^FROM .*@sha256:[0-9a-f]{64}' image/Dockerfile; then | |
| echo "::error::Base image is not digest-pinned in image/Dockerfile." | |
| exit 1 | |
| fi | |
| echo "OK: base pinned by digest" | |
| - name: The egress proxy image is pinned by digest | |
| run: | | |
| # Same reasoning as the base image above, and one step sharper. deploy/docker-compose.yml names | |
| # valkey/valkey:8, a floating minor, and that is FINE for a queue: a bad pull breaks loudly and | |
| # spends nothing. The egress proxy IS the allowlist (REQ-EGRESS-ALLOWLIST), so a floating tag | |
| # would let an upstream rebuild change what every job container may reach, with no commit | |
| # anywhere and nothing failing. | |
| if ! grep -qE 'image: ubuntu/squid@sha256:[0-9a-f]{64}' deploy/docker-compose.yml; then | |
| echo "::error::The egress proxy image in deploy/docker-compose.yml is not digest-pinned." | |
| exit 1 | |
| fi | |
| echo "OK: egress proxy pinned by digest" | |
| - name: The example pi-packages.json pins exact versions | |
| run: | | |
| # Third-party pi packages an operator stages into the global overlay are pinned by the SAME | |
| # reasoning as pi itself (CONST-PI-VERSION-PINNED, INT-PI-PACKAGES-FILE-CONTRACT). The grep | |
| # above is deliberately NOT extended to them: the operator's real pi-packages.json lives on | |
| # their host, not in this repo, so there is nothing here to grep. The REAL enforcement is | |
| # import-pi's load-time refusal (EXACT_VERSION_RE in worker/src/packages.mjs), which stages | |
| # nothing at all when a version is a range, a tag, or a wildcard. This step only keeps the | |
| # EXAMPLE we ship honest -- an example carrying "^1.4.2" would teach the failure mode the | |
| # refusal exists to prevent. | |
| bad=$(grep -oE '"version"[[:space:]]*:[[:space:]]*"[^"]*"' pi-packages.example.json \ | |
| | grep -vE '"version"[[:space:]]*:[[:space:]]*"[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?"' || true) | |
| if [ -n "$bad" ]; then | |
| echo "::error::pi-packages.example.json carries a non-exact version: $bad" | |
| exit 1 | |
| fi | |
| echo "OK: example package pins are exact" | |
| - name: The example triggers.json names only an image this repo produces | |
| run: | | |
| # Same reasoning again, and the same honest limit. A trigger's run.image is operator data | |
| # (INT-TRIGGERS-FILE-CONTRACT): the real file lives on the operator's host, so there is nothing | |
| # here to grep, and the REAL enforcement is parseTriggers' load-time refusal plus the worker's | |
| # pre-spend `docker image inspect`. This step only keeps the EXAMPLE we ship honest -- an example | |
| # naming "my-python:latest" would teach a tag nobody has, and turn a first run into a refusal. | |
| # The list grew when this repo started producing a second image: Azure DevOps' only CLI is the | |
| # Azure CLI plus its devops extension (~1 GB of Python), which does not belong in the image every | |
| # job runs, so it ships as image/Dockerfile.azure and an azure trigger names it via run.image | |
| # (DES-IMAGE-DECLARES-ITS-FORGES). Any OTHER tag here is still refused. | |
| bad=$(grep -oE '"image"[[:space:]]*:[[:space:]]*"[^"]*"' triggers.example.json \ | |
| | grep -vE '"image"[[:space:]]*:[[:space:]]*"pi-job:(latest|azure)"' || true) | |
| if [ -n "$bad" ]; then | |
| echo "::error::triggers.example.json names an image this repo does not produce: $bad" | |
| exit 1 | |
| fi | |
| echo "OK: the example names only images this repo produces" | |
| - name: glab is pinned by version AND checksum | |
| run: | | |
| # `gh` installs from an apt channel and is this image's one unpinned dependency. A second CLI | |
| # must not repeat that: an unattended upstream change would land inside a container holding a | |
| # repo credential, which is CONST-PI-VERSION-PINNED's reasoning one layer out. The version and | |
| # both per-architecture digests are literals in the Dockerfile, and this asserts they stay. | |
| for name in GLAB_VERSION GLAB_SHA256_AMD64 GLAB_SHA256_ARM64; do | |
| if ! grep -qE "^ARG ${name}=[0-9a-f.]+$" image/Dockerfile; then | |
| echo "::error::${name} is missing or not a literal pin in image/Dockerfile." | |
| exit 1 | |
| fi | |
| done | |
| if ! grep -q 'sha256sum -c -' image/Dockerfile; then | |
| echo "::error::The glab download is not checksum-verified." | |
| exit 1 | |
| fi | |
| echo "OK: glab pinned by version and verified by checksum" | |
| - name: tea is pinned by version AND checksum | |
| run: | | |
| # Identical reasoning to glab's, and issue #61 asks for it by name -- "a second CLI should not | |
| # repeat that". `gh` remains this image's one unpinned dependency; every CLI added since is | |
| # pinned to an exact version and verified against a per-architecture digest. | |
| for name in TEA_VERSION TEA_SHA256_AMD64 TEA_SHA256_ARM64; do | |
| if ! grep -qE "^ARG ${name}=[0-9a-f.]+$" image/Dockerfile; then | |
| echo "::error::${name} is missing or not a literal pin in image/Dockerfile." | |
| exit 1 | |
| fi | |
| done | |
| echo "OK: tea pinned by version and verified by checksum" | |
| - name: The forge's MCP servers are pinned by version AND checksum or lockfile | |
| run: | | |
| # DES-FORGE-MCP, and the reasoning glab's and tea's steps give: each runs inside a container holding the job's | |
| # forge token. github-mcp-server is a release tarball, pinned by an exact version and both per-architecture | |
| # digests; mcp-gitlab is an npm package, pinned by an exact version in its own package.json and installed with | |
| # `npm ci` from the lockfile committed beside it. worker/test/forge-mcp-pins.test.mjs holds the same, and | |
| # more. | |
| for name in GITHUB_MCP_SERVER_VERSION GITHUB_MCP_SERVER_SHA256_AMD64 GITHUB_MCP_SERVER_SHA256_ARM64; do | |
| if ! grep -qE "^ARG ${name}=[0-9a-f.]+$" image/Dockerfile; then | |
| echo "::error::${name} is missing or not a literal pin in image/Dockerfile." | |
| exit 1 | |
| fi | |
| done | |
| if ! grep -qE '"@zereight/mcp-gitlab": "[0-9]+\.[0-9]+\.[0-9]+"' image/forge-mcp/gitlab/package.json; then | |
| echo "::error::image/forge-mcp/gitlab/package.json does not pin @zereight/mcp-gitlab to an exact version." | |
| exit 1 | |
| fi | |
| if [ ! -f image/forge-mcp/gitlab/package-lock.json ] || ! grep -q 'npm ci --omit=dev --ignore-scripts' image/Dockerfile; then | |
| echo "::error::mcp-gitlab is not installed with npm ci from its committed lockfile." | |
| exit 1 | |
| fi | |
| echo "OK: github-mcp-server pinned by version and checksum, mcp-gitlab by version and lockfile" | |
| - name: The azure image variant pins the Azure CLI and declares its forges | |
| run: | | |
| # The variant is built from the default image, so it inherits the base digest, the pi pin and the | |
| # pi-version label rather than re-declaring them -- a second copy of those is a second thing to | |
| # forget to bump. What it MUST declare itself is the Azure CLI version and the forge list the | |
| # worker's pre-spend preflight reads: a label naming a forge whose CLI is absent would turn a | |
| # loud refusal into a paid container that fails at step 3 (DES-IMAGE-DECLARES-ITS-FORGES). | |
| if ! grep -qE '^ARG AZURE_CLI_VERSION=[0-9]' image/Dockerfile.azure; then | |
| echo "::error::AZURE_CLI_VERSION is missing or not a literal pin in image/Dockerfile.azure." | |
| exit 1 | |
| fi | |
| if ! grep -q 'dev.pi-dispatch.forges=.*azure' image/Dockerfile.azure; then | |
| echo "::error::image/Dockerfile.azure does not declare azure in dev.pi-dispatch.forges." | |
| exit 1 | |
| fi | |
| if ! grep -q 'dev.pi-dispatch.forges=' image/Dockerfile; then | |
| echo "::error::image/Dockerfile does not declare dev.pi-dispatch.forges." | |
| exit 1 | |
| fi | |
| echo "OK: the azure variant pins its CLI and declares its forges" | |
| no-automatic-merge: | |
| name: no automatic merge (CONST-MERGE-NEVER-AUTOMATIC) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: No merge API call anywhere | |
| run: | | |
| # CONST-MERGE-NEVER-AUTOMATIC: "grep is the test". A match is a merge symbol reaching | |
| # the code — the human review step is the last line and is not negotiable. | |
| # | |
| # The pattern has TWO surfaces since GitLab landed (issue #42). A grep that still only knew | |
| # GitHub's would pass while `PUT /projects/:id/merge_requests/:iid/merge` sat in the worker: | |
| # the constraint would read as enforced and would not be. | |
| # | |
| # The scope grew with the automated pi bump (issue #587): .github/workflows/pi-bump.yml and the scripts it runs | |
| # hold a write token, which is exactly where an automatic merge, an auto-merge switch or a self-approval would | |
| # go. This file stays OUT of scope, because it holds the patterns themselves. Approval joins the list because on | |
| # a branch that required an approving review it would be a merge by another name. Reading a pull request's | |
| # reviews is refused only in the bump's own scope: the receiver's poller GETs them on purpose (issue #66), | |
| # and a GET there is not the hazard. | |
| # | |
| # grep exits 0 on a match, 1 on none and 2 on an error, a missing path among them. Only 1 is clean: an `if grep` | |
| # would read a renamed or deleted path in scope as "no match" and pass. worker/test/no-automatic-merge.test.mjs | |
| # runs this script on a planted tree and on the real one. | |
| runtime=(worker/src image/runner receiver/src) | |
| bump=(.github/workflows/pi-bump.yml .github/scripts) | |
| found=0 | |
| check() { | |
| set +e | |
| grep -rn --exclude-dir=node_modules "$@" | |
| rc=$? | |
| set -e | |
| if [ "$rc" -ne 1 ]; then found=1; fi | |
| } | |
| check -E 'pulls\.merge|gh[[:space:]]+pr[[:space:]]+merge|mergePullRequest|merge_pull_request|/pulls/.*/merge|glab mr merge|merge_requests/[^ ]*/merge|acceptMergeRequest' "${runtime[@]}" "${bump[@]}" | |
| check -iE 'auto[-_]?merge' "${runtime[@]}" "${bump[@]}" | |
| check -E 'gh[[:space:]]+pr[[:space:]]+review.*(--approve|[[:space:]]-a([[:space:]]|$))|addPullRequestReview|createReview|event"?[[:space:]]*[=:]?[[:space:]]*"?APPROVE' "${runtime[@]}" "${bump[@]}" | |
| check -E '/pulls/[^ ]*/reviews' "${bump[@]}" | |
| if [ "$found" -ne 0 ]; then | |
| echo "::error::A merge or approval symbol is listed above, or a path in scope is missing. CONST-MERGE-NEVER-AUTOMATIC forbids any merge call, and the scope must exist to be checked." | |
| exit 1 | |
| fi | |
| echo "OK: no merge or approval symbols (both forges, and the pi bump workflow)" | |
| - name: No test may steal the runner's stdout | |
| run: | | |
| # Issue #266, and "grep is the test" again. `node --test` runs each file in a CHILD PROCESS that | |
| # serialises its own results over `process.stdout`. A test helper that replaces | |
| # `process.stdout.write` and holds the replacement across an `await` destroys whatever result | |
| # frames flush in that window: three tests in worker/test/start-wiring.test.mjs were reported as | |
| # never existing at all -- no name, no count, `fail 0`, exit code 0. | |
| # | |
| # The tests still RAN, and a failure in one still turned the build red -- as `not ok <file>` with | |
| # no test name, which is the part that makes a red build unreadable. | |
| # | |
| # Every entry point takes an injected `write`, so there is no legitimate reason left to reassign | |
| # the global and this grep carries no allowlist. stderr is deliberately not matched: it is not the | |
| # channel the runner reports on. | |
| if grep -rnE '^[^/]*process\.stdout\.write *=' worker/test receiver/test admin/test image/runner/test; then | |
| echo "::error::A test reassigns process.stdout.write. Inject the code under test's own `write` seam instead (issue #266)." | |
| exit 1 | |
| fi | |
| echo "OK: no test steals the runner's results channel" | |
| contract-tests: | |
| name: pinned assumptions still hold (offline, no API key) | |
| runs-on: ubuntu-latest | |
| # Issue #657: a hung test once held this job for five hours, Actions' own cap being six. A healthy run takes about | |
| # fifteen minutes; forty-five leaves room for a slow runner and still frees the check the same hour. | |
| timeout-minutes: 45 | |
| services: | |
| # A real Valkey backs the queue integration test (enqueue + dedup). AOF is not needed for a | |
| # test run; the point is a genuine Redis-protocol server, not the fake. | |
| valkey: | |
| image: valkey/valkey:8 | |
| ports: | |
| - 6379:6379 | |
| options: >- | |
| --health-cmd "valkey-cli ping" --health-interval 10s --health-timeout 3s --health-retries 5 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version-file: ".nvmrc" # pi requires >=22.19.0; engine-strict enforces it | |
| - run: npm ci --no-audit --no-fund | |
| # A second, not two minutes: the fast half of issue #293's guard, so an author who paired a dated | |
| # fixture with a default clock hears about it before the suite has finished starting. | |
| - name: No dated fixture rides a default clock (issue #293) | |
| run: node .github/scripts/dated-fixture-check.mjs | |
| # A revision row that renders with the wrong number of cells loses everything after the second one, | |
| # and a diff cannot show that: the row looks whole in the source and is cut on the page (issue #388). | |
| - name: Every revision row renders as two cells (issue #388) | |
| run: node .github/scripts/revision-row-check.mjs | |
| # A rebase once committed conflict markers into a spec's revision table, and every other guard was green: they | |
| # render as plain text and no test reads a spec's history (PR #457). The worker suite runs the same scan. | |
| - name: No merge-conflict marker in a tracked file (PR #457) | |
| run: node .github/scripts/conflict-marker-check.mjs | |
| # The other half of issue #351's pair. This one is the fast hint: it sees a CALL SHAPE, so it | |
| # names the file and the fix in a second. It cannot see a missing cleanup at all -- delete an | |
| # after() hook and it stays green -- which is what the $TMPDIR count below is for. | |
| - name: No test makes an OS temp directory it never removes (issue #351) | |
| run: node .github/scripts/temp-dir-check.mjs | |
| # The scariest assertions in this project are FREE: every trap lives at the resource-loader | |
| # boundary (pure) or at before_agent_start (which fires strictly before any provider HTTP | |
| # call). No API key, no tokens, no flake, no excuse not to run them on every build. | |
| # | |
| # PI_DISPATCH_REQUIRE_*_TESTS=1 turns a skip into a hard failure. A skipped assertion is an | |
| # UNVERIFIED assertion, and "skipped = pass" is precisely the reasoning that lets a | |
| # guardrail-less agent ship green. VALKEY_TEST_URL activates the queue integration test. | |
| # | |
| # Cron/scheduler assumptions this run pins (BullMQ 5.80.4), each SILENT if it drifts: | |
| # - no-backfill / no-overlap / deterministic repeat: jobId -> specs/design.md:206-216 | |
| # - must-handle -10/-11 (schedule edit else silent no-op) -> specs/design.md:231 | |
| # - scheduler jobs bypass maxStalledCount (stall carve-out) -> specs/constitution.md:203-216 | |
| # Exercised by worker/test/cron.integration.test.mjs (VALKEY_TEST_URL + PI_DISPATCH_REQUIRE_WORKER_TESTS gate it live). | |
| - name: Contract tests -- guardrails, -nc, exit codes, env allowlist, queue, receiver enqueue, cron (all required) | |
| env: | |
| PI_DISPATCH_REQUIRE_LOADER_TESTS: "1" | |
| PI_DISPATCH_REQUIRE_WORKER_TESTS: "1" | |
| PI_DISPATCH_REQUIRE_RECEIVER_TESTS: "1" | |
| VALKEY_TEST_URL: "redis://127.0.0.1:6379" | |
| run: | | |
| # Issue #351's ORACLE, and the reason the grep guard one step up is not enough: that one sees | |
| # a call shape, this one measures the defect. A test file that makes a temp directory and never | |
| # removes it leaves entries here whatever the call looked like, so a helper that forgets to | |
| # register, or an after() hook someone deletes, fails HERE and nowhere else. Measured before | |
| # this was armed: one local run left 954 entries, and this job runs the suite three times. | |
| # | |
| # ON ITS OWN TMPDIR, and that is not tidiness. The bare runner default is /tmp, which npm, | |
| # docker and the runner's own tooling also write to while the suite runs, so counting there | |
| # would make a REQUIRED check flake on entries no test made. A fresh directory the suite alone | |
| # writes to makes the count exact and the failure honest. | |
| # | |
| # A FAILING SUITE SKIPS THIS COUNT, deliberately and unavoidably: the default shell is `bash -e`, | |
| # so a non-zero `npm test` ends the step before the count runs. The build is red either way, and | |
| # counting leftovers from a run that did not finish would say nothing about cleanup. | |
| # | |
| # ONLY THIS RUN IS MEASURED, of the three this job makes and the fourth in release.yml. That is | |
| # enough for DETECTION: all four run the same suite, so a test that leaks leaks here. Wrapping | |
| # the others would buy disk on the runner, which dies with the job, and cost another export | |
| # per step. | |
| export TMPDIR="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/suite-tmpdir" | |
| rm -rf "$TMPDIR" | |
| mkdir -p "$TMPDIR" | |
| npm test | |
| # Two fixed-name caches are made by TOOLING rather than by any test: `node-compile-cache` is | |
| # Node's own, and `jiti` is the transpiler pi's node_modules brings in for the admin tests. | |
| # Excluded by EXACT NAME, which is why this is not the kind of allowlist `test-count-check.mjs` | |
| # warns erodes: `mkdtemp` always appends random characters, so no test directory can ever | |
| # match one of these, and the list cannot grow to cover test output by accident. | |
| left=$(ls -A "$TMPDIR" | grep -vxE 'jiti|node-compile-cache' | wc -l) | |
| echo "entries left in the suite's own TMPDIR: $left" | |
| if [ "$left" -gt 0 ]; then | |
| ls -A "$TMPDIR" | grep -vxE 'jiti|node-compile-cache' | head -20 | |
| echo "::error::The suite left $left entries in its own TMPDIR. Every test directory must come from the workspace helper (import { tempDir } from \"./helpers/temp-dir.mjs\"), which removes them in an after() hook. See issue #351." | |
| exit 1 | |
| fi | |
| # The suite above can report success while silently losing tests, which is the failure issue #266 | |
| # was: `fail 0`, exit 0, and three tests missing from the output entirely. This compares what | |
| # `node --test` reports against what the in-process runner actually runs, per file. It runs the suite | |
| # a second time (~2 min) and that is the price of the count meaning something. | |
| - name: Every declared test is actually reported (issue #266) | |
| env: | |
| PI_DISPATCH_REQUIRE_LOADER_TESTS: "1" | |
| PI_DISPATCH_REQUIRE_WORKER_TESTS: "1" | |
| PI_DISPATCH_REQUIRE_RECEIVER_TESTS: "1" | |
| VALKEY_TEST_URL: "redis://127.0.0.1:6379" | |
| run: node .github/scripts/test-count-check.mjs | |
| # Issue #293. A test that judges a fixed fixture against an un-injected clock passes until the wall | |
| # clock drifts past that subject's own window, then fails in CI on a tree nobody touched -- issue | |
| # #284 was exactly that, and `main` froze until it was fixed. Running the suite 399 days from now | |
| # converts that latent fuse into a failure on the commit that introduces it. | |
| # | |
| # A STEP rather than a job, deliberately: a new job is a new required-check CONTEXT, which only a | |
| # branch-protection edit can make blocking, and until someone remembers to make it so the guard is | |
| # advisory. Here it blocks on day one at no protection cost. | |
| # | |
| # Blocking rather than `continue-on-error`, for this workflow's own stated reason two steps up: | |
| # "skipped = pass is precisely the reasoning that lets a guardrail-less agent ship green". An | |
| # advisory red nobody must fix is that reasoning wearing a different hat. | |
| # | |
| # Cost, stated: this is the suite's third full run in this job (the count check runs it twice). | |
| # Measured green in this posture at +399 days before it was armed. | |
| - name: The suite survives a clock 399 days from now (issues #284, #293) | |
| env: | |
| PI_DISPATCH_REQUIRE_LOADER_TESTS: "1" | |
| PI_DISPATCH_REQUIRE_WORKER_TESTS: "1" | |
| PI_DISPATCH_REQUIRE_RECEIVER_TESTS: "1" | |
| VALKEY_TEST_URL: "redis://127.0.0.1:6379" | |
| TZ: "UTC" | |
| NODE_OPTIONS: "--import file://${{ github.workspace }}/.github/scripts/shift-clock.mjs" | |
| run: | | |
| if ! npm test; then | |
| echo "::error::A test failed only because the wall clock moved 399 days forward. That is issue #284's class: a fixed instant in the test and a subject built on the DEFAULT Date.now, so the fixture and the clock drift apart until the subject's own window closes over it. Two correct fixes: pass an injected clock to the subject (now: () => AT), or stamp the fixture through the same clock the subject reads. worker/test/run-mirror.test.mjs is the worked example (AT + anchored). Do NOT move the fixture date forward, which re-arms the fuse." | |
| exit 1 | |
| fi | |
| image: | |
| name: the job image holds its contract | |
| runs-on: ubuntu-latest | |
| # CORE vs RUNNER, and the line matters. CORE is what ANY image must satisfy to be nameable in a | |
| # trigger's `run.image` -- it lives in image/verify-image.sh, which this job calls, so the portable | |
| # checklist has ONE definition rather than a copy here and a copy in the docs. RUNNER asserts properties | |
| # of the runner THIS repo ships (its exact refusal strings, a path under /app a foreign image need not | |
| # have) and stays here, because it is not portable. That distinction is the difference between | |
| # "conformant" and "built from this repo", which docs/job-image.md and OQ-012 both have to draw. | |
| # | |
| # An operator verifying THEIR image runs the script directly, on the host that holds it. It cannot be a | |
| # workflow input: jobs launch with --pull=never, so the images pi-dispatch can run are exactly the ones | |
| # on the worker's own machine, and a GitHub runner has no access to those. | |
| env: | |
| IMAGE_REF: pi-job:ci | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Build | |
| run: docker build -f image/Dockerfile -t pi-job:ci . | |
| # --- CORE: nothing may make docker fetch an image (INT-CONTAINER-RUNTIME-CONTRACT) --- | |
| # The argv-side proof is the frozen-intent deepEqual in worker/test/docker-run.test.mjs, which runs in | |
| # contract-tests. THIS step asserts docker's own behaviour, and exists only because a flag an older | |
| # docker accepts and silently disregards looks identical to a working one -- the exact silent class | |
| # this workflow is for. Same register as the PI_OFFLINE probe further down. | |
| - name: "CORE: --pull=never is honoured, not silently ignored" | |
| run: | | |
| if docker run --rm --pull=never pi-dispatch-definitely-not-present:ci true 2>/dev/null; then | |
| echo "::error::docker ran an absent image under --pull=never -- the flag is being ignored." | |
| exit 1 | |
| fi | |
| echo "OK: --pull=never refuses an image that is not local" | |
| # --- INT-SDK-SESSION-OPTIONS --- | |
| # `pi --mode print` does not exist: --mode accepts text|json|rpc, and --print/-p is a separate | |
| # boolean. --help rather than a real prompt, because a real prompt needs a paid API key and a | |
| # contract test that costs money is a contract test that gets disabled. | |
| # --entrypoint is required: ENTRYPOINT is the runner and ignores CMD, so `docker run img pi ...` | |
| # would silently run the runner instead of pi and the assertion would test nothing. | |
| # The CORE checklist, from the single definition an operator also runs against their own image | |
| # (docs/job-image.md). Kept as a script rather than inlined steps so the portable half cannot drift | |
| # from what the docs promise: pi present, CapEff=0, non-root, /job:ro enforced by the kernel, a | |
| # writable agent dir, guardrails present AND unwritable, Chromium rendering a real page, fonts, and | |
| # for an image claiming anyUid a writable home and a render as an arbitrary uid (issue #341), and for one | |
| # claiming costCap an offline job with a fake key under a cap of 0 stopped before its first call (issue #501), and | |
| # for one claiming modelPolicy an offline job whose list lacks its model stopped before its first call (issue #502). | |
| - name: "CORE: the conformance checklist any nameable image must pass" | |
| # VERIFY_REQUIRE_LANDLOCK (issue #683): this runner's kernel has Landlock, so pi-landlock's confinement must be | |
| # PROVED here, never skipped as it is on a host without Landlock (Docker Desktop's LinuxKit kernel). | |
| run: VERIFY_REQUIRE_LANDLOCK=1 ./image/verify-image.sh "$IMAGE_REF" | |
| # --- INT-CONTAINER-RUNTIME-CONTRACT (costCap, issue #501) --- | |
| # The checklist above proves a CLAIMED capability; it cannot notice one this image stopped claiming. Dropping | |
| # `costCap` from the Dockerfile label would otherwise pass every check here, and the worker reads the label to | |
| # decide whether a cost-capped job may run on this image (refusing it as job-image-cost-cap-unsupported when the token | |
| # is missing), so the token this repo's image must carry is bound here. | |
| - name: "RUNNER: the built image declares costCap" | |
| run: | | |
| docker image inspect --format '{{index .Config.Labels "dev.pi-dispatch.capabilities"}}' "$IMAGE_REF" | tr ',' '\n' | grep -qx "costCap" \ | |
| || { echo "::error::the built image's capabilities label does not declare costCap -- a cost-capped job could not be admitted on this image."; exit 1; } | |
| echo "OK: the label declares costCap, and the CORE checklist ran its proof" | |
| # --- INT-CONTAINER-RUNTIME-CONTRACT (modelPolicy, issue #502) --- | |
| # Same reason as costCap above: the checklist proves a claimed token and cannot notice a dropped one, and the | |
| # worker sends PI_ALLOWED_MODELS only to an image that declares modelPolicy, so this repo's image must carry it. | |
| - name: "RUNNER: the built image declares modelPolicy" | |
| run: | | |
| docker image inspect --format '{{index .Config.Labels "dev.pi-dispatch.capabilities"}}' "$IMAGE_REF" | tr ',' '\n' | grep -qx "modelPolicy" \ | |
| || { echo "::error::the built image's capabilities label does not declare modelPolicy -- a job with an allowed-model list could not be admitted on this image."; exit 1; } | |
| echo "OK: the label declares modelPolicy, and the CORE checklist ran its proof" | |
| # --- INT-RUNNER-EXIT-CODE-PROTOCOL (exitAuth, issue #545) --- | |
| # Same reason again: an image that dropped exitAuth would still pass, and the worker would quietly go back to | |
| # reading an exit line the job's own tools can forge. The CORE checklist proves the claim when it is made. | |
| - name: "RUNNER: the built image declares exitAuth" | |
| run: | | |
| docker image inspect --format '{{index .Config.Labels "dev.pi-dispatch.capabilities"}}' "$IMAGE_REF" | tr ',' '\n' | grep -qx "exitAuth" \ | |
| || { echo "::error::the built image's capabilities label does not declare exitAuth -- the worker would read an exit line the job's tools can forge."; exit 1; } | |
| echo "OK: the label declares exitAuth, and the CORE checklist ran its proof" | |
| # --- INT-CONTAINER-RUNTIME-CONTRACT (mcp, REQ-MCP-OPERATOR-SERVERS) --- | |
| # Same reason: the worker refuses every run.mcp job on an image without the token, so this repo's image must carry | |
| # it, and the CORE checklist's offline stub-server job is what proves it. | |
| - name: "RUNNER: the built image declares mcp" | |
| run: | | |
| docker image inspect --format '{{index .Config.Labels "dev.pi-dispatch.capabilities"}}' "$IMAGE_REF" | tr ',' '\n' | grep -qx "mcp" \ | |
| || { echo "::error::the built image's capabilities label does not declare mcp -- a job whose trigger names MCP servers could not be admitted on this image."; exit 1; } | |
| echo "OK: the label declares mcp, and the CORE checklist ran its proof" | |
| # --- INT-CONTAINER-RUNTIME-CONTRACT (forgeMcp, DES-FORGE-MCP) --- | |
| # Same reason again: a github or gitlab trigger naming `forge` in run.mcp is refused on an image without the | |
| # token, and the CORE checklist runs both baked servers offline as the job user to prove it. | |
| - name: "RUNNER: the built image declares forgeMcp" | |
| run: | | |
| docker image inspect --format '{{index .Config.Labels "dev.pi-dispatch.capabilities"}}' "$IMAGE_REF" | tr ',' '\n' | grep -qx "forgeMcp" \ | |
| || { echo "::error::the built image's capabilities label does not declare forgeMcp -- a job whose trigger names the forge's own MCP server could not be admitted on this image."; exit 1; } | |
| echo "OK: the label declares forgeMcp, and the CORE checklist ran its proof" | |
| # --- INT-RUNNER-EXIT-CODE-PROTOCOL (resources and the supervisor, issue #596) --- | |
| # Two runs, each handed a key. The first is the real offline job (a cap of 0 stops it before any call, nothing is | |
| # dialled) at the job's real 4g: its signed exit line must carry `resources` with a peak above 0, read from the | |
| # job's own cgroup. The second keeps the image's real entrypoint and supervisor but mounts a stand-in over | |
| # run-job.mjs that allocates and fills anonymous memory far past a 64m limit, so the kernel kills it for memory, | |
| # and the supervisor between the init and the runner must survive that (the runner's tree has the top OOM score) | |
| # and write the signed line itself: `code: 137, reason: "oom-killed"`, `by: "supervisor"`, `oomKills` above 0 | |
| # and `memPeak` at 90% of the limit or more (the worker confirms an OOM only there). That line is the only thing | |
| # that tells the worker a 137 was memory and not something a retry could pass, on Docker and Podman alike. | |
| # A stand-in and not the real runner at 64m (review of issue #596): near the limit with a cold page cache the | |
| # kernel reclaims and refaults pi's own files instead of killing anything (measured: rootless Podman on Fedora | |
| # still running at 100 s, Docker Desktop stopped at 90 s), so the real runner could hang the job to its timeout. | |
| # Filled anonymous memory cannot be reclaimed without swap (`--memory-swap` equals `--memory`), so the kill is | |
| # prompt and certain. | |
| - name: "RUNNER: the exit line reports resources, and a runner killed for memory is reported (issue #596)" | |
| run: | | |
| JOB=$(mktemp -d) | |
| echo "Reply with the single word ok." > "$JOB/prompt.md" | |
| cat > "$JOB/grow.mjs" <<'GROW' | |
| // Allocate and fill 16 MiB at a time and keep every block: anonymous memory, touched, never released. | |
| const kept = []; | |
| for (;;) kept.push(Buffer.allocUnsafe(16 * 1024 * 1024).fill(1)); | |
| GROW | |
| chmod -R a+rX "$JOB" | |
| key=$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n') | |
| run_keyed() { | |
| printf '%s\n' "$key" | timeout 300 docker run -i --rm --init --network none --cap-drop=ALL --security-opt no-new-privileges \ | |
| --pids-limit=512 "$@" -v "$JOB:/job:ro" \ | |
| -e PI_PROVIDER=anthropic -e PI_MODEL=claude-sonnet-4-5-20250929 -e PI_MAX_TURNS=1 \ | |
| -e ANTHROPIC_API_KEY=sk-ant-not-a-real-key -e PI_MAX_COST_MICROS=0 -e PI_EXIT_AUTH=stdin -e PI_JOB_ID=r596 \ | |
| "$IMAGE_REF" 2>&1 | |
| } | |
| signed_last() { | |
| line=$(echo "$1" | grep '"event":"exit"' | tail -1) | |
| mac=$(echo "$line" | sed -nE 's/.*,"auth":"([0-9a-f]{64})"\}$/\1/p') | |
| body=$(echo "$line" | sed -E 's/,"auth":"[0-9a-f]{64}"\}$/}/') | |
| want=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$key" | awk '{print $NF}') | |
| [ -n "$mac" ] && [ "$mac" = "$want" ] && echo "$body" | |
| } | |
| set +e | |
| out=$(run_keyed --memory=4g --memory-swap=4g --cpus=2); code=$? | |
| set -e | |
| body=$(signed_last "$out") || { echo "$out" | tail -5; echo "::error::the job's last exit line is not signed with its key (exit $code)"; exit 1; } | |
| echo "$body" | grep -Eq '"resources":\{"memPeak":[1-9][0-9]*,' \ | |
| || { echo "$body"; echo "::error::the signed exit line carries no resources block with a peak: the record would say nothing about memory"; exit 1; } | |
| set +e | |
| out=$(run_keyed --memory=64m --memory-swap=64m -v "$JOB/grow.mjs:/app/image/runner/run-job.mjs:ro"); code=$? | |
| set -e | |
| rm -rf "$JOB" | |
| [ "$code" = 137 ] || { echo "$out" | tail -5; echo "::error::at 64m the stand-in exited $code, want 137: it was not killed for memory, so this proves nothing"; exit 1; } | |
| body=$(signed_last "$out") || { echo "$out" | tail -5; echo "::error::a runner killed for memory left no signed exit line: the supervisor did not survive it, did not write one, or the entrypoint does not start it"; exit 1; } | |
| if ! echo "$body" | grep -q '"code":137,"reason":"oom-killed"' || ! echo "$body" | grep -q '"by":"supervisor"' || ! echo "$body" | grep -Eq '"oomKills":[1-9]'; then | |
| echo "$body"; echo "::error::the supervisor's line does not say oom-killed, by the supervisor, with oomKills above 0"; exit 1 | |
| fi | |
| # 90% of 64 MiB, rounded up, as the worker computes it (peakReachedLimit). | |
| peak=$(echo "$body" | sed -nE 's/.*"memPeak":([0-9]+).*/\1/p') | |
| [ "${peak:-0}" -ge 60397978 ] \ | |
| || { echo "$body"; echo "::error::the supervisor's line reports a memPeak of ${peak:-none}, under 90% of the 64m limit: the worker would read this kill as the host's and retry it"; exit 1; } | |
| echo "OK: resources on the signed line, and a runner killed for memory ends on a signed oom-killed line at its limit" | |
| # --- DES-JOB-USER-INFERRED-READ-BACK-ON-REQUEST (issue #341) --- | |
| # The runner's own user is uid 1001, the image's, which is exactly the one host shape that can never show | |
| # the bug: every job mount works for it with no --user at all. So the check runs as a DIFFERENT uid, 1234 | |
| # in its own primary group and in the docker group, against this job's real daemon, which enforces | |
| # bind-mount ownership. The script drives the real modules (the facts reader and resolver, the preparers | |
| # and session store, the builder, the cleanup, doctor's decision and read-back) and the real runner, with no | |
| # network and no key, and it refuses to pass unless its runner and read-back negative controls all ran and | |
| # failed as they must. RUNNER, not CORE: it asserts this repository's runner and worker, not what any | |
| # nameable image must do. | |
| # PATH and the node binary are carried explicitly, because sudo's secure_path replaces PATH and setup-node's | |
| # node lives in the tool cache, off it; -H gives pdjob its own HOME, so host git does not read the runner's. | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version-file: ".nvmrc" | |
| - run: npm ci --no-audit --no-fund | |
| - name: "RUNNER: every job mount works for a worker that is not uid 1001 (issue #341)" | |
| run: | | |
| sudo useradd -m --uid 1234 --user-group pdjob | |
| sudo usermod -aG docker pdjob | |
| sudo cp -a "$GITHUB_WORKSPACE" /srv/pd | |
| sudo chown -R pdjob:pdjob /srv/pd | |
| sudo install -d -o pdjob -g pdjob -m 0755 /srv/pd-jobs | |
| sudo -H -u pdjob env "PATH=$PATH" PI_JOBS_DIR=/srv/pd-jobs "$(command -v node)" /srv/pd/.github/scripts/job-user-e2e.mjs "$IMAGE_REF" | |
| # --- REQ-UPSTREAM-CONTRACT-TESTS (issue #587): the zero-spend provider smoke --- | |
| # Every other in-image check runs offline against a fake provider. This one runs a real job through the entrypoint | |
| # against Anthropic itself, with a key that has a real key's shape and is not one: it must reach the provider, get | |
| # its 401, be metered on every method the meter wraps, exit 2 provider-auth-refused and cost $0, uncapped and | |
| # under a dollar cap. That is what tells a pi bump (the automated ones from pi-bump.yml included) that the pinned | |
| # pi still reaches a real provider through the meter. When a run fails, a control fetch from the same image with the | |
| # same fake key decides: only Anthropic's 401 makes it the image's fault; a network error, a 5xx, a 529 or a 429 | |
| # fails as "infrastructure, re-run", never read as a verdict either way. | |
| # | |
| # A STEP IN A REQUIRED JOB, SO IT DECIDES FOR ITSELF WHETHER TO RUN. The job must always report, so it cannot be | |
| # path-filtered; the smoke depends on a real provider, so a pull request that cannot move its result does not pay | |
| # for it. It runs on a push to main, the schedule and a manual run, on the pi bump's own branch, and on a pull | |
| # request that changes a pi pin site, the lockfile, image/, the smoke itself or this workflow, computed here against the base | |
| # (fake-key-smoke.mjs's smokeWanted). On pull_request the checkout is the merge commit, so a diff against the | |
| # base sha is exactly the pull request's change. | |
| - name: "RUNNER: a fake-key job reaches the provider, is refused with a 401, is metered and costs nothing (issue #587)" | |
| env: | |
| BASE_SHA: ${{ github.event.pull_request.base.sha }} | |
| run: | | |
| : > "$RUNNER_TEMP/changed.txt" | |
| if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then | |
| git fetch --no-tags --depth=1 origin "$BASE_SHA" | |
| git diff --name-only --no-renames "$BASE_SHA" HEAD > "$RUNNER_TEMP/changed.txt" | |
| fi | |
| node .github/scripts/fake-key-smoke.mjs "$IMAGE_REF" --changed "$RUNNER_TEMP/changed.txt" | |
| # --- INT-CONTAINER-JOB-INPUTS / REQ-GLOBAL-PI-OVERLAY --- | |
| # A staged package that did not mount must be refused BEFORE any spend. This is the one failure | |
| # that pi will not report for us: it SKIPS a local package source that does not resolve -- no | |
| # error, no diagnostic -- so an unmounted package otherwise runs the flow to a clean exit 0 | |
| # without the tools it was written for, and reports success for work it could not have done. | |
| # | |
| # NO --entrypoint here, deliberately: the assertion is about the RUNNER, which is the entrypoint. | |
| # The exit code alone would be vacuous (a missing /job/prompt.md is also exit 2), so the message | |
| # is asserted too -- and the second run is the differential that proves the packages check fires | |
| # FIRST, before the prompt is even read. | |
| - name: "RUNNER: a staged package path that did not mount is refused pre-spend (exit 2)" | |
| run: | | |
| run_runner() { | |
| docker run --rm --network none \ | |
| -e PI_PROVIDER=anthropic -e PI_MODEL=claude-sonnet-4-5-20250929 -e PI_MAX_TURNS=1 "$@" \ | |
| "$IMAGE_REF" 2>&1 | |
| } | |
| set +e | |
| out=$(run_runner -e PI_PACKAGES=/opt/pi-global/packages/definitely-not-mounted) | |
| code=$? | |
| set -e | |
| echo "$out" | |
| [ "$code" = "2" ] || { echo "::error::PI_PACKAGES pointing at a missing dir exited $code, want 2 (policy, not retried)."; exit 1; } | |
| echo "$out" | grep -q "staged package path does not exist" \ | |
| || { echo "::error::exit 2 came from something other than the staged-package check -- the assertion is vacuous."; exit 1; } | |
| # Differential: with PI_PACKAGES unset the SAME invocation dies later, on the missing prompt. | |
| # That is what shows the packages check runs before the job inputs are read, i.e. pre-spend. | |
| set +e | |
| out2=$(run_runner) | |
| code2=$? | |
| set -e | |
| [ "$code2" = "2" ] || { echo "::error::control run exited $code2, want 2"; exit 1; } | |
| echo "$out2" | grep -q "missing job input" \ | |
| || { echo "::error::control run did not fail on the prompt -- the ordering claim is unproven."; exit 1; } | |
| echo "OK: staged-package refusal precedes the job-input read" | |
| # --- REQ-EGRESS-ALLOWLIST (issue #427) --- | |
| # With egress armed the REAL runner's provider call must go through the proxy. It did not: loading pi replaced the | |
| # env-proxy dispatcher NODE_USE_ENV_PROXY installs, so every egress-armed job went direct and died on its internal | |
| # network, while every canary stayed green, because each was a plain fetch that never loads pi. This runs the | |
| # entrypoint itself, with a key that is not a key, on an --internal network whose only other member is a stub | |
| # proxy that logs each CONNECT and refuses it: nothing leaves the runner, and nothing is spent. The control run | |
| # without NODE_USE_ENV_PROXY must reach no proxy at all, which is what shows the first run's CONNECT is the | |
| # runner's restore and not the environment alone. Measured before the fix: the armed run's CONNECT never came. | |
| - name: "RUNNER: with egress armed, the provider call goes through the proxy (issue #427)" | |
| run: | | |
| NET=pd-runner-proxy-check | |
| PROXY=pd-runner-stub-proxy | |
| docker network create --internal "$NET" >/dev/null | |
| docker run -d --name "$PROXY" --network "$NET" --entrypoint node "$IMAGE_REF" -e ' | |
| require("http").createServer((q, s) => s.writeHead(405).end()).on("connect", (q, sock) => { | |
| console.log("CONNECT " + q.url); | |
| sock.end("HTTP/1.1 403 Forbidden\r\n\r\n"); | |
| }).listen(3128);' >/dev/null | |
| JOB=$(mktemp -d) | |
| echo "Reply with the single word ok." > "$JOB/prompt.md" | |
| chmod -R a+rX "$JOB" | |
| # Bounded, and it must reach the runner's own exit line: a container that never started (125, a name | |
| # clash, the image) or a runner that hung would otherwise read as "no CONNECT", which the control below | |
| # would take for success. A runner that refused before its first turn still prints an exit line; the two | |
| # runs differ only in NODE_USE_ENV_PROXY, so such a refusal would silence the armed run too and fail above. | |
| run_runner() { | |
| out=$(timeout 300 docker run --rm --network "$NET" -v "$JOB:/job:ro" \ | |
| -e PI_PROVIDER=anthropic -e PI_MODEL=claude-sonnet-4-5-20250929 -e PI_MAX_TURNS=1 \ | |
| -e ANTHROPIC_API_KEY=sk-ant-not-a-real-key \ | |
| -e HTTPS_PROXY="http://$PROXY:3128" -e HTTP_PROXY="http://$PROXY:3128" -e NO_PROXY=localhost,127.0.0.1 "$@" \ | |
| "$IMAGE_REF" 2>&1) || true | |
| echo "$out" | grep -q '"event":"exit"' \ | |
| || { echo "$out" | tail -20; echo "::error::the runner did not run to its exit line ($*), so this run proves nothing either way."; exit 1; } | |
| last_exit=$(echo "$out" | grep '"event":"exit"' | tail -1) | |
| } | |
| run_runner -e NODE_USE_ENV_PROXY=1 | |
| armed_exit=$last_exit | |
| armed=$(docker logs "$PROXY" 2>&1) | |
| run_runner | |
| control=$(docker logs "$PROXY" 2>&1) | |
| # And the stub was still listening through the control, or its silence is a dead proxy's. | |
| [ "$(docker inspect -f '{{.State.Running}}' "$PROXY")" = "true" ] \ | |
| || { echo "::error::the stub proxy stopped during the check, so the control run's silence proves nothing."; exit 1; } | |
| docker rm -f "$PROXY" >/dev/null | |
| docker network rm "$NET" >/dev/null | |
| echo "$armed" | |
| # The runner's own exit line says WHY when it is not the proxy: on an SELinux host, for one, an unlabelled /job | |
| # stops the runner at `job-inputs-unreadable` before any request (a local rerun there needs `:Z` on the mount). | |
| echo "$armed" | grep -qx "CONNECT api.anthropic.com:443" \ | |
| || { echo "$armed_exit"; echo "::error::with egress armed the runner's provider call never reached the proxy (its exit line is above); every egress-armed job dies at its first turn."; exit 1; } | |
| [ "$control" = "$armed" ] \ | |
| || { echo "::error::the control run without NODE_USE_ENV_PROXY reached the proxy too, so the armed run proves nothing about the restore."; exit 1; } | |
| echo "OK: the armed runner's provider call went through the proxy, and the unarmed one did not" | |
| # --- INT-TRIGGERS-FILE-CONTRACT (run.excludeTools, issue #291) --- | |
| # A name the pinned pi would silently ignore must be refused BEFORE any spend, in-container too: | |
| # pi matches excludeTools entries by exact name or `*` pattern, so an unknown name is a no-op with no | |
| # diagnostic -- the job would run WITH the tool the trigger says to remove and record a clean | |
| # exit. The worker's loader makes this branch unreachable for worker-built containers; this step | |
| # is the end-to-end proof that the ENTRYPOINT wires the env var to the refusal for the skew and | |
| # hand-run cases. The exit code alone would be vacuous (the packages step above says why), so | |
| # the message is asserted too -- and the valid-exclusion run is the differential proving a legal | |
| # denylist parses clean and the run proceeds to the NEXT gate (the missing prompt), i.e. the | |
| # refusal is the membership check and it sits pre-spend. The inspect at the end is the one place | |
| # in CI that binds the Dockerfile's capability token: without it, dropping `excludeTools` from | |
| # the label is caught only by every flagged job refusing in production -- loudly, but paid-for. | |
| - name: "RUNNER: an exclusion the pinned pi does not know is refused pre-spend (exit 2)" | |
| run: | | |
| run_runner() { | |
| docker run --rm --network none \ | |
| -e PI_PROVIDER=anthropic -e PI_MODEL=claude-sonnet-4-5-20250929 -e PI_MAX_TURNS=1 "$@" \ | |
| "$IMAGE_REF" 2>&1 | |
| } | |
| set +e | |
| out=$(run_runner -e PI_EXCLUDE_TOOLS=not-a-tool) | |
| code=$? | |
| set -e | |
| echo "$out" | |
| [ "$code" = "2" ] || { echo "::error::PI_EXCLUDE_TOOLS=not-a-tool exited $code, want 2 (policy, not retried)."; exit 1; } | |
| echo "$out" | grep -q "is not a tool the pinned pi knows" \ | |
| || { echo "::error::exit 2 came from something other than the exclusion membership check -- the assertion is vacuous."; exit 1; } | |
| # Differential: a VALID exclusion dies later, on the missing prompt -- the membership check | |
| # passed it and the run reached the next gate, which is the pre-spend ordering claim. | |
| set +e | |
| out2=$(run_runner -e PI_EXCLUDE_TOOLS=bash,edit) | |
| code2=$? | |
| set -e | |
| [ "$code2" = "2" ] || { echo "::error::valid-exclusion control exited $code2, want 2"; exit 1; } | |
| echo "$out2" | grep -q "missing job input" \ | |
| || { echo "::error::a valid exclusion did not reach the job-input read -- over-validation, or the ordering claim is unproven."; exit 1; } | |
| docker image inspect --format '{{index .Config.Labels "dev.pi-dispatch.capabilities"}}' "$IMAGE_REF" | grep -q "excludeTools" \ | |
| || { echo "::error::the built image's capabilities label does not declare excludeTools -- the worker would refuse every flagged job on this image."; exit 1; } | |
| echo "OK: unknown exclusions refuse pre-spend, valid ones pass, the label declares the capability" | |
| # --- INT-CONTAINER-RUNTIME-CONTRACT (PI_OFFLINE=1) --- | |
| # pi's resolver shells out to a REAL `npm install` for any npm:/git: source unless offline mode | |
| # is on, and ~/.pi/agent IS writable in the container -- so without this flag an unresolved | |
| # source is a live network install of third-party code at agent runtime. With it, | |
| # resolvePackageSources takes the `installMissing() -> false -> continue` branch: a clean skip. | |
| # | |
| # ONLY the PI_OFFLINE=1 half is asserted, on purpose. The obvious negative half (same run with | |
| # the variable unset, expecting failure) would be VACUOUS here: under `--network none` that run | |
| # fails because the network is off, not because pi refused, so a green would be asserting that | |
| # docker's --network none works. Distinguishing the two would mean asserting on npm's retry | |
| # timing or its error text, neither of which is a contract we can pin. The other direction -- | |
| # unset, WITH a network -- is exactly the live third-party install this flag exists to prevent, | |
| # so it is not something to run in CI either. The real enforcement is that the worker sets | |
| # PI_OFFLINE=1 on every job and the runner re-asserts it in-process before the loader is built. | |
| # | |
| # The three suppression flags MIRROR image/runner/src/loader.mjs (noContextFiles:false, | |
| # noExtensions:false, noSkills:true) rather than suppressing everything, so this probe exercises | |
| # the configuration a job actually runs. They had drifted to all-true, which was the old posture | |
| # (CONST-NO-CONTEXT-FILES-MANDATORY, since amended); the probe still passed, which is exactly the | |
| # problem -- a probe that green-lights a config nothing uses proves nothing about the one that does. | |
| # | |
| # Syncing them does NOT weaken what is asserted, because the npm: source does not travel through | |
| # the flag: resolveExtensionSources reads additionalExtensionPaths into cliExtensionPaths BEFORE | |
| # the branch, and noExtensions only decides whether DISCOVERED paths merge in afterwards. But a | |
| # bare `count === 0` WOULD have become weaker -- with discovery on it also asserts "nothing was | |
| # discovered anywhere", which is true here (the image's ~/.pi/agent is empty and /tmp has no .pi/) | |
| # and would silently stop being the thing under test the day either changed. So the assertion is | |
| # a DIFFERENCE against the same loader without the npm: source: whatever discovery contributes, | |
| # it contributes to both, and only the unresolvable source can move the number. | |
| - name: "RUNNER: PI_OFFLINE=1 turns an unresolvable npm: source into a clean skip" | |
| run: | | |
| PROBE='import { DefaultResourceLoader, SettingsManager, getAgentDir } from "@earendil-works/pi-coding-agent"; | |
| const build = (additionalExtensionPaths) => new DefaultResourceLoader({ | |
| cwd: "/tmp", agentDir: getAgentDir(), settingsManager: SettingsManager.inMemory({}), | |
| noContextFiles: false, noSkills: true, noExtensions: false, | |
| additionalExtensionPaths, | |
| }); | |
| const count = async (paths) => { const l = build(paths); await l.reload(); return l.getExtensions().extensions.length; }; | |
| const baseline = await count([]); | |
| const withSource = await count(["npm:@pi-dispatch/definitely-does-not-exist"]); | |
| if (withSource !== baseline) { console.error("the unresolvable npm: source changed the loaded set: " + baseline + " -> " + withSource); process.exit(1); } | |
| console.log("OK: offline skip, nothing installed (loaded " + baseline + " either way)");' | |
| docker run --rm --network none -e PI_OFFLINE=1 -e PROBE="$PROBE" \ | |
| --entrypoint sh "$IMAGE_REF" -c 'cd /app && node --input-type=module -e "$PROBE"' \ | |
| || { echo "::error::PI_OFFLINE=1 did not make an unresolvable npm: source a clean skip -- pi may have tried to install at load time."; exit 1; } | |
| # --- DES-USAGE-METER-VIA-API-PROVIDER-REGISTRY (trap (g) in INT-SDK-SESSION-OPTIONS) --- | |
| # The meter's compat half must bind pi-coding-agent's OWN pi-ai: that is the pi-ai pi hands its | |
| # extensions, whose module-level api-provider registry legacy extension calls dispatch through, and a | |
| # copy pi does not import meters nothing while reporting success. Since the pi 0.99.1 pin (issue #509) | |
| # a session's own calls go through ModelRuntime instead, so the process-wide choke point is | |
| # ModelRuntime.prototype; the step after this one asserts that half. | |
| # | |
| # What this step deliberately does NOT assert is a layout. Up to pi 0.99.1 pi-coding-agent's shrinkwrap | |
| # nested pi-ai under it; pi 1.0.1 dropped the shrinkwrap, and at the 1.0.3 pin (issue #587) pi-ai sits at | |
| # /app/node_modules beside it. resolvePiAiCompat finds pi's copy by pi-coding-agent's own node_modules | |
| # lookup (piOwnPackageDir), whatever the layout, and image/runner/test/pinned-api.test.mjs pins that the | |
| # first candidate IS the module pi's VIRTUAL_MODULES hands an extension. | |
| # | |
| # So assert what IS load-bearing here, and assert it by running OUR OWN resolver inside the built | |
| # image rather than re-deriving the layout in shell -- a shell re-derivation encodes a rule the | |
| # runner does not follow, since resolvePiAiCompat wraps BOTH lookups in tryResolve and skips an | |
| # unresolvable candidate instead of throwing. No `cd /app`: the import is an absolute file:// URL | |
| # and resolvePiAiCompat resolves from usage-meter.mjs's own URL, which is what lets the real runner | |
| # work from cwd=/workspace. | |
| - name: "RUNNER: the meter's resolver offers pi-coding-agent's own pi-ai first, inside the image" | |
| run: | | |
| PROBE='import { existsSync } from "node:fs"; | |
| import { fileURLToPath } from "node:url"; | |
| import { resolvePiAiCompat } from "file:///app/image/runner/src/usage-meter.mjs"; | |
| const candidates = resolvePiAiCompat(); | |
| if (candidates.length === 0) { console.error("resolvePiAiCompat() offered NO pi-ai compat candidate at all"); process.exit(1); } | |
| if (candidates[0].tag !== "pi") { console.error("first candidate is tagged " + candidates[0].tag + ", want pi"); process.exit(1); } | |
| if (!existsSync(fileURLToPath(candidates[0].url))) { console.error("the candidate pi itself uses is not on disk"); process.exit(1); } | |
| const mod = await import(candidates[0].url); | |
| for (const fn of ["getApiProvider", "getApiProviders", "registerApiProvider", "resetApiProviders"]) { | |
| if (typeof mod[fn] !== "function") { console.error("the compat copy pi itself uses no longer exports " + fn + "()"); process.exit(1); } | |
| } | |
| console.log("OK: " + candidates.length + " candidate(s), the copy pi itself uses first, compat surface intact");' | |
| docker run --rm --network none -e PROBE="$PROBE" \ | |
| --entrypoint sh "$IMAGE_REF" -c 'node --input-type=module -e "$PROBE"' \ | |
| || { echo "::error::The usage meter no longer offers pi-coding-agent's OWN pi-ai first in this image. That copy is the pi-ai pi hands its extensions, whose registry the meter's compat half arms for legacy extension calls; the process-wide choke point is ModelRuntime.prototype, and installProcessUsageMeter accepts the compat copy by identity with pi's VIRTUAL_MODULES, so this step is about the ORDER and the surface, not the decision. If the layout moved, re-verify in this order: piOwnPackageDir and resolvePiAiCompat in image/runner/src/usage-meter.mjs, the compat exports the probe and the wrapper call (getApiProvider/getApiProviders/registerApiProvider/resetApiProviders, createAssistantMessageEventStream), trap (g) in specs/interfaces.md, and image/runner/test/pinned-api.test.mjs."; exit 1; } | |
| # The process-wide half (issue #509): since the pi 0.99.1 pin a session dispatches every model call through | |
| # a ModelRuntime instance, so the meter wraps ModelRuntime.prototype, and the brake's stream factory | |
| # comes from the accepted compat copy. Asserted inside the built image: the install must succeed, accept | |
| # pi's own compat copy, and have a brake for a capped job. | |
| - name: "RUNNER: the usage meter installs on ModelRuntime inside the image, compat half on pi's own pi-ai" | |
| run: | | |
| PROBE='import { ModelRuntime } from "@earendil-works/pi-coding-agent"; | |
| import { createUsageMeter, installProcessUsageMeter } from "file:///app/image/runner/src/usage-meter.mjs"; | |
| const logged = []; | |
| const handle = await installProcessUsageMeter({ ModelRuntime, meter: createUsageMeter({ maxTokens: 1000 }), log: (event, fields) => logged.push({ event, ...fields }) }); | |
| handle.uninstall(); | |
| const line = logged.find((l) => l.event === "usage_meter"); | |
| if (!handle.ok || line?.compat !== "pi" || line?.brake !== true) { console.error(JSON.stringify(logged)); process.exit(1); } | |
| console.log("OK: " + JSON.stringify(line));' | |
| docker run --rm --network none -e PROBE="$PROBE" \ | |
| --entrypoint sh "$IMAGE_REF" -c 'cd /app && node --input-type=module -e "$PROBE"' \ | |
| || { echo "::error::The usage meter did not install on ModelRuntime with its compat half and brake inside the image."; exit 1; } | |
| # Issue #500 part C: a pi child in a job meters itself. The runner points every descendant at a ledger directory and | |
| # at the child preload (NODE_OPTIONS); a pi child then loads child-meter.ts first and writes its usage to its own | |
| # ledger file. Asserted inside the image, as an arbitrary uid (the anyUid job user), because three things only | |
| # the image can show: the preload and the .ts meter are readable by any uid, pi's loader transforms the meter with | |
| # /app read-only, and `pi` on PATH (the bundle, through the .bin link) is recognised by realpath. Offline: the | |
| # provider is an extension that answers every call itself. | |
| - name: "RUNNER: a pi child started from PATH meters itself into the ledger, as an arbitrary uid" | |
| run: | | |
| PROVIDER='import { createAssistantMessageEventStream } from "@earendil-works/pi-ai"; | |
| export default function (pi) { | |
| pi.registerProvider("fake", { baseUrl: "http://127.0.0.1:1", apiKey: "k", api: "fake-api", | |
| models: [{ id: "m1", name: "m1", api: "fake-api", reasoning: false, input: ["text"], cost: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0 }, contextWindow: 100000, maxTokens: 4096 }], | |
| streamSimple(model) { const s = createAssistantMessageEventStream(); queueMicrotask(() => s.push({ type: "done", reason: "stop", message: { role: "assistant", content: [{ type: "text", text: "ok" }], api: model.api, provider: model.provider, model: model.id, usage: { input: 500, output: 277, cacheRead: 0, cacheWrite: 0, totalTokens: 777, cost: { input: 0.0005, output: 0.000277, cacheRead: 0, cacheWrite: 0, total: 0.000777 } }, stopReason: "stop", timestamp: Date.now() } })); return s; } }); | |
| }' | |
| docker run --rm --network none --user 4242:4242 -e HOME=/tmp -e PROVIDER="$PROVIDER" \ | |
| --entrypoint sh "$IMAGE_REF" -c ' | |
| set -eu | |
| cd /tmp | |
| printf "%s\n" "$PROVIDER" > /tmp/fake-provider.ts | |
| L=$(mktemp -d /tmp/pi-dispatch-meter-XXXXXX) | |
| PI_CODING_AGENT_DIR=$(mktemp -d) PI_OFFLINE=1 PI_DISPATCH_CHILD_LEDGER="$L" NODE_OPTIONS=--import=file:///app/image/runner/src/child-preload.mjs \ | |
| pi --mode json -p --no-session -e /tmp/fake-provider.ts --model fake/m1 hello < /dev/null > /dev/null | |
| node --input-type=module -e " | |
| import { foldChildLedgers } from \"file:///app/image/runner/src/usage-meter.mjs\"; | |
| const fold = foldChildLedgers({ dir: process.argv[1] }); | |
| const [entry] = fold.files.values(); | |
| if (fold.processes !== 1 || fold.unmetered !== 0 || fold.totals.total !== 777 || entry?.state !== \"done\") { console.error(JSON.stringify({ processes: fold.processes, unmetered: fold.unmetered, totals: fold.totals, state: entry?.state })); process.exit(1); } | |
| console.log(\"OK: one metered child ledger, 777 tokens, done\");" "$L"' \ | |
| || { echo "::error::A pi child in the image did not meter itself into the ledger. Check, in order: child-preload.mjs and child-meter.ts are in the image and readable by any uid, image/runner/src/child-preload.mjs still recognises the package bin by realpath, and child-meter.ts still gets the child's own pi-ai and ModelRuntime from pi's loader (image/runner/test/child-meter.integration.test.mjs runs the same route outside the image)."; exit 1; } | |
| # Issue #500 part D: a NESTED runner (pi's stock subagent example spawns `node run-job.mjs <pi args>`) runs as the pi | |
| # CLI, metered into the job runner's ledger, and never as a second job. Asserted inside the image, as an arbitrary | |
| # uid, under the runner's own exec-only node, the way the example spawns it (process.execPath): one ledger of 777 | |
| # tokens, `done`, and no exit line on stdout. With no ledger directory it exits 2 before pi runs. | |
| - name: "RUNNER: a nested runner runs as the pi CLI and meters itself into the inherited ledger, as an arbitrary uid" | |
| run: | | |
| PROVIDER='import { createAssistantMessageEventStream } from "@earendil-works/pi-ai"; | |
| export default function (pi) { | |
| pi.registerProvider("fake", { baseUrl: "http://127.0.0.1:1", apiKey: "k", api: "fake-api", | |
| models: [{ id: "m1", name: "m1", api: "fake-api", reasoning: false, input: ["text"], cost: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0 }, contextWindow: 100000, maxTokens: 4096 }], | |
| streamSimple(model) { const s = createAssistantMessageEventStream(); queueMicrotask(() => s.push({ type: "done", reason: "stop", message: { role: "assistant", content: [{ type: "text", text: "ok" }], api: model.api, provider: model.provider, model: model.id, usage: { input: 500, output: 277, cacheRead: 0, cacheWrite: 0, totalTokens: 777, cost: { input: 0.0005, output: 0.000277, cacheRead: 0, cacheWrite: 0, total: 0.000777 } }, stopReason: "stop", timestamp: Date.now() } })); return s; } }); | |
| }' | |
| docker run --rm --network none --user 4242:4242 -e HOME=/tmp -e PROVIDER="$PROVIDER" \ | |
| --entrypoint sh "$IMAGE_REF" -c ' | |
| set -eu | |
| cd /tmp | |
| printf "%s\n" "$PROVIDER" > /tmp/fake-provider.ts | |
| L=$(mktemp -d /tmp/pi-dispatch-meter-XXXXXX) | |
| rc=0 | |
| PI_DISPATCH_RUNNER_PID=1 PI_DISPATCH_CHILD_LEDGER=/tmp/gone /opt/pi-dispatch/runner-node /app/image/runner/run-job.mjs -p hello < /dev/null > /dev/null 2>&1 || rc=$? | |
| [ "$rc" = 2 ] || { echo "a nested runner with no ledger exited $rc, not 2"; exit 1; } | |
| PI_CODING_AGENT_DIR=$(mktemp -d) PI_OFFLINE=1 PI_DISPATCH_RUNNER_PID=1 PI_DISPATCH_CHILD_LEDGER="$L" NODE_OPTIONS=--import=file:///app/image/runner/src/child-preload.mjs \ | |
| /opt/pi-dispatch/runner-node /app/image/runner/run-job.mjs --mode json -p --no-session -e /tmp/fake-provider.ts --model fake/m1 hello < /dev/null > /tmp/nested.out | |
| if grep -q "\"event\":\"exit\"" /tmp/nested.out; then echo "a nested runner wrote an exit line"; exit 1; fi | |
| grep -q "\"type\":\"agent_end\"" /tmp/nested.out || { echo "a nested runner did not run as the pi CLI"; exit 1; } | |
| ls /tmp | grep -c "^pi-dispatch-meter-" | grep -qx 1 || { echo "a nested runner made a ledger directory of its own"; exit 1; } | |
| node --input-type=module -e " | |
| import { foldChildLedgers } from \"file:///app/image/runner/src/usage-meter.mjs\"; | |
| const fold = foldChildLedgers({ dir: process.argv[1] }); | |
| const [entry] = fold.files.values(); | |
| if (fold.processes !== 1 || fold.unmetered !== 0 || fold.totals.total !== 777 || entry?.state !== \"done\") { console.error(JSON.stringify({ processes: fold.processes, unmetered: fold.unmetered, totals: fold.totals, state: entry?.state })); process.exit(1); } | |
| console.log(\"OK: a nested runner is one metered child ledger, 777 tokens, done\");" "$L"' \ | |
| || { echo "::error::A nested runner in the image did not run as a metered pi CLI. Check, in order: run-job.mjs calls isNestedRunner before anything else, child-route.mjs runAsPiCli loads pi's dist/cli.js, and child-preload.mjs gives a nested runner the pi CLI's route (image/runner/test/child-meter.integration.test.mjs runs the same route outside the image)."; exit 1; } | |
| # Issue #500 part E: the parent's children hook in the image, with real pi children, as an arbitrary uid. The test | |
| # file ships in the image (image/runner is copied whole). On Linux its detector tests run too, so `skipped 0` is the | |
| # proof that /proc was read: a pi child whose spawner cleared the environment is found and counted unmetered. | |
| - name: "RUNNER: the parent folds a pi child's ledger, stops it at the cap and finds an unmetered pi child, as an arbitrary uid" | |
| run: | | |
| docker run --rm --network none --user 4242:4242 -e HOME=/tmp -e TMPDIR=/tmp -e PI_DISPATCH_REQUIRE_LOADER_TESTS=1 \ | |
| --entrypoint node "$IMAGE_REF" --test --test-reporter=tap /app/image/runner/test/child-watch.integration.test.mjs > watch.tap 2>&1 \ | |
| && grep -qx "# fail 0" watch.tap && grep -qx "# skipped 0" watch.tap \ | |
| || { cat watch.tap; echo "::error::The parent's children hook did not fold, stop or detect in the image. Check, in order: run-job.mjs hands createChildWatch to installProcessUsageMeter, child-watch.mjs still matches pi's titles (pi, pi-rpc) and entry files, and the child meter still reads STOP (image/runner/test/child-watch.test.mjs and child-watch.integration.test.mjs run the same rules outside the image)."; exit 1; } | |
| admin-extension-canary: | |
| # The admin extension is the default front door (issue #96): it is TESTED against an exact pi | |
| # (SUPPORTED_PI_VERSION, locked to the admin devDependency pin by admin/test/load.test.mjs) but | |
| # declares a "*" peer, because pi never installs peers for host-provided packages and an exact | |
| # peer pin ERESOLVEs every plain-npm consumer whose pi differs by a patch -- the full reasoning | |
| # lives in .github/scripts/admin-pi-canary.mjs's header. A wildcard peer without a canary is a | |
| # hope; this job is the canary. It installs pi@latest into a SCRATCH dir and asserts the admin's | |
| # pinned assumptions against THAT install: the pinned-api needle list, the USED_API members on | |
| # ExtensionAPI, the runtime VERSION export, and an actual load-and-register of the built bundle. | |
| # | |
| # Failure protocol: a red run here means pi moved underneath the admin. Retest locally against | |
| # the new pi, bump SUPPORTED_PI_VERSION and the admin devDependency pin TOGETHER (load.test.mjs | |
| # locks them to each other), and republish @edgehero/pi-dispatch-admin. | |
| name: the admin extension survives latest pi (canary) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version-file: ".nvmrc" | |
| # NEVER install anything extra into the repo root. admin/test/load.test.mjs and | |
| # admin/test/pinned-extension-api.test.mjs anchor on the HOISTED PIN the lockfile resolves; a | |
| # root `npm install @earendil-works/pi-coding-agent@latest` would silently flip both from | |
| # pinned-version assertions into HEAD assertions -- green-lighting exactly the drift they | |
| # exist to catch. The @latest copy goes into a mktemp scratch dir outside the checkout. | |
| - run: npm ci --no-audit --no-fund | |
| - run: node admin/build.mjs | |
| - name: Install pi@latest into a scratch dir (never the repo) | |
| run: | | |
| SCRATCH=$(mktemp -d) | |
| echo "SCRATCH=$SCRATCH" >> "$GITHUB_ENV" | |
| cd "$SCRATCH" | |
| npm init -y >/dev/null | |
| # Distinguish failures (release.yml's npm-view doctrine): an install error here is | |
| # infrastructure -- network, registry, npm itself -- NOT a canary verdict about pi, and | |
| # reading it as either "pi broke us" or "all clear" would be a guess. Fail loudly instead. | |
| if ! npm install @earendil-works/pi-coding-agent@latest --no-audit --no-fund; then | |
| echo "::error::npm install @earendil-works/pi-coding-agent@latest failed -- an infrastructure failure, not a pi-drift verdict. Re-run before reading anything into it." | |
| exit 1 | |
| fi | |
| node -p "'canary pi: ' + require('$SCRATCH/node_modules/@earendil-works/pi-coding-agent/package.json').version" | |
| - name: Run the canary against the scratch pi | |
| run: | | |
| # The bundle is copied INTO the scratch dir so its bare pi import resolves against | |
| # scratch/node_modules -- ESM resolves bare specifiers from the importing file's own | |
| # location, which is what keeps the repo's hoisted pin out of the probe entirely. The | |
| # canary script re-copies fresh and asserts (a)-(d); see its header for the mechanics. | |
| cp admin/dist/index.mjs "$SCRATCH/admin-bundle.mjs" | |
| node .github/scripts/admin-pi-canary.mjs "$SCRATCH" | |
| mcp-canary: | |
| # The job's MCP (REQ-MCP-OPERATOR-SERVERS) rests on pi internals the runner drives directly: the MCP extension's | |
| # events and options, the transports' options and exit hook, the connect order its readiness follows | |
| # (image/runner/src/mcp.mjs). pinned-api.test.mjs and mcp.test.mjs hold those at the PIN, inside the required | |
| # checks; this job runs the same two files against pi@LATEST, through a resolve hook that sends only the | |
| # @earendil-works scope to a scratch install (.github/scripts/mcp-pi-canary.mjs), so a pi release that moves any of | |
| # it shows up here before the daily bump pull request does. | |
| # | |
| # NOT a required check, deliberately: its red is drift in a release this repository does not run yet, and a | |
| # required check could then block every unrelated pull request until the bump is done. Failure protocol: read the | |
| # failing assertion, then fix image/runner/src/mcp.mjs (or the pin's copy) on the bump pull request, never the | |
| # assertion. An install failure is infrastructure and says nothing either way. | |
| name: the job's MCP survives latest pi (canary) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version-file: ".nvmrc" | |
| # The repository's own install, as every other job has it; pi@latest goes into a scratch dir outside it. | |
| - run: npm ci --no-audit --no-fund | |
| - name: Install pi@latest into a scratch dir (never the repo) | |
| run: | | |
| SCRATCH=$(mktemp -d) | |
| echo "MCP_CANARY_PI=$SCRATCH" >> "$GITHUB_ENV" | |
| cd "$SCRATCH" | |
| npm init -y >/dev/null | |
| if ! npm install @earendil-works/pi-coding-agent@latest --no-audit --no-fund; then | |
| echo "::error::npm install of pi@latest failed -- an infrastructure failure, not a pi-drift verdict. Re-run before reading anything into it." | |
| exit 1 | |
| fi | |
| node -p "'canary pi: ' + require('$SCRATCH/node_modules/@earendil-works/pi-coding-agent/package.json').version" | |
| - name: Run the job's MCP tests against the scratch pi | |
| env: | |
| PI_DISPATCH_REQUIRE_LOADER_TESTS: "1" | |
| run: | | |
| node --import ./.github/scripts/mcp-pi-canary.mjs --test image/runner/test/mcp.test.mjs \ | |
| || { echo "::error::The job's MCP does not hold against pi@latest: read the failing test, then fix image/runner/src/mcp.mjs on the bump pull request."; exit 1; } | |
| node --import ./.github/scripts/mcp-pi-canary.mjs --test image/runner/test/codemode-models.integration.test.mjs \ | |
| || { echo "::error::codemode's models.* no longer reach the runner's meter on pi@latest: read the failing test before the bump pull request turns them on."; exit 1; } | |
| # A renamed pin would match no name below and pass with nothing run, so the count is asked too. | |
| node --import ./.github/scripts/mcp-pi-canary.mjs --test --test-reporter=tap --test-name-pattern="MCP|McpExtensionOptions|transports the runner|isTransientMcpError|pi-mcp is the copy|connect runs in the order" image/runner/test/pinned-api.test.mjs > pins.tap \ | |
| || { cat pins.tap; echo "::error::A pi internal the job's MCP rests on moved in pi@latest: read the failing pin, re-read that pi code, and fix the runner (or the pin's copy) on the bump pull request."; exit 1; } | |
| PASSED=$(sed -n 's/^# pass //p' pins.tap) | |
| if [ "${PASSED:-0}" -lt 6 ]; then cat pins.tap; echo "::error::Only ${PASSED:-0} of the MCP pins ran: a pin was renamed past the pattern above. Update the pattern."; exit 1; fi | |
| host-pi-canary: | |
| # `worker/src/host-pi.mjs` MIRRORS private pi internals (issue #102): where `pi install` puts a | |
| # package, and whether `pi config` disabled a resource. pi exports no public answer to either, which | |
| # is the residual recorded as OQ-018. `worker/test/host-pi.pinned.test.mjs` gates the PINNED version | |
| # inside contract-tests as a hard failure; this job is the advance warning against `latest`, and both | |
| # read one shared needle list (PINNED_PI_NEEDLES) so the gate and the canary cannot drift apart. | |
| # | |
| # Its OWN job rather than a step on the admin canary, deliberately. Sharing that job made a run named | |
| # "the admin extension survives latest pi" go red for a reason that had nothing to do with the admin | |
| # extension, which is a mislabelled signal however accurate the annotation underneath it was. Separate | |
| # jobs also mean an admin failure no longer hides this result, and vice versa. | |
| # | |
| # Failure protocol: drift here is a `::warning::` and the job stays GREEN, because nothing shipped is | |
| # broken -- the worker pins pi, and the pinned test is what fails when that pin is wrong. A RED run | |
| # means the canary could not execute at all (pi did not install), which is infrastructure and says | |
| # nothing either way about drift. See the script header for the full reasoning. | |
| name: host-pi mirrors survive latest pi (canary) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version-file: ".nvmrc" | |
| # No `npm ci`: this job reads two source files and one scratch install, and never imports the | |
| # repo's own pinned pi -- installing it would be the drift-hiding mistake the admin canary's own | |
| # comment warns about. | |
| - name: Install pi@latest into a scratch dir (never the repo) | |
| run: | | |
| SCRATCH=$(mktemp -d) | |
| echo "SCRATCH=$SCRATCH" >> "$GITHUB_ENV" | |
| cd "$SCRATCH" | |
| npm init -y >/dev/null | |
| # Same doctrine as the admin canary: an install error is infrastructure, NOT a drift verdict. | |
| if ! npm install @earendil-works/pi-coding-agent@latest --no-audit --no-fund; then | |
| echo "::error::npm install @earendil-works/pi-coding-agent@latest failed -- an infrastructure failure, not a pi-drift verdict. Re-run before reading anything into it." | |
| exit 1 | |
| fi | |
| node -p "'canary pi: ' + require('$SCRATCH/node_modules/@earendil-works/pi-coding-agent/package.json').version" | |
| - name: "pi's package-install paths and enablement grammar still read the way host-pi.mjs assumes" | |
| run: node .github/scripts/host-pi-canary.mjs "$SCRATCH" |