Skip to content

refactor(worker): share the platform browser opener; add the graph ar… #248

refactor(worker): share the platform browser opener; add the graph ar…

refactor(worker): share the platform browser opener; add the graph ar… #248

# 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:
push:
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"
# 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:
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: 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 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 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.
if grep -rnE 'pulls\.merge|gh pr merge|mergePullRequest|merge_pull_request|[Aa]utoMerge|/pulls/.*/merge|glab mr merge|merge_requests/[^ ]*/merge|acceptMergeRequest' worker/src image/runner receiver/src; then
echo "::error::Merge symbol found. CONST-MERGE-NEVER-AUTOMATIC forbids any merge call."
exit 1
fi
echo "OK: no merge symbols (both forges)"
contract-tests:
name: pinned assumptions still hold (offline, no API key)
runs-on: ubuntu-latest
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
# 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: npm test
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.
- name: "CORE: the conformance checklist any nameable image must pass"
run: ./image/verify-image.sh "$IMAGE_REF"
# --- 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"
# --- 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 must bind pi-coding-agent's OWN nested pi-ai: that is the module-level api-provider
# registry a running session dispatches through, and a bare `import "@earendil-works/pi-ai"` binds
# something else and meters nothing while reporting success.
#
# What this step deliberately does NOT assert is "pi-ai is installed twice". That is FALSE HERE, and
# an earlier version of this step failed on it. The image installs the ROOT lockfile against
# `image/runner/package.json` ONLY, which declares pi-coding-agent and @playwright/cli and NOT
# pi-ai; the hoisted copy a dev checkout has is the WORKER's dependency, and the worker is not
# installed in this image. So in the image the nested copy is the ONLY copy and a bare specifier
# does not resolve at all. The dual-copy fact is still pinned, just where it is true:
# image/runner/test/pinned-api.test.mjs asserts it in the contract-tests job, which does a full
# workspace install. Duplicating it here would only re-assert a dev-box layout inside a container
# that does not have it.
#
# 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 nested 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 !== "nested") { console.error("first candidate is tagged " + candidates[0].tag + ", want nested"); process.exit(1); }
if (!existsSync(fileURLToPath(candidates[0].url))) { console.error("the nested candidate 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 nested compat copy no longer exports " + fn + "()"); process.exit(1); }
}
console.log("OK: " + candidates.length + " candidate(s), nested 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 NESTED pi-ai first in this image. That copy owns the module-level api-provider registry every session dispatches through, so it is the only one worth metering; installProcessUsageMeter still DECIDES by runtime mutation probe and would fall back, so this step is about the ORDER and the surface, not the decision. If the layout moved, re-verify in this order: resolvePiAiCompat's nested-path derivation 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 -- which pins the dual-copy layout in contract-tests, where the worker's hoisted pi-ai is installed too."; 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"
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"