diff --git a/.agents/pm/history/pm-github-m8u2.jsonl b/.agents/pm/history/pm-github-m8u2.jsonl new file mode 100644 index 0000000..160e49d --- /dev/null +++ b/.agents/pm/history/pm-github-m8u2.jsonl @@ -0,0 +1,2 @@ +{"ts":"2026-08-26T19:33:40.186Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"f559dcd5a5f387bc7221097a","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"create","patch":[{"op":"replace","path":"/body","value":"Trusted publishing replaces the stored credential. The registry mints a short lived credential from the workflow OIDC identity, so there is no secret left to expire. Requires a one time configuration on npmjs.com binding this package to unbraind/pm-github and the release.yml workflow."},{"op":"add","path":"/metadata/id","value":"pm-github-m8u2"},{"op":"add","path":"/metadata/title","value":"The release job authenticated with a stored npm token that expired, so publishing stopped while every other gate stayed green"},{"op":"add","path":"/metadata/description","value":"The Publish npm package step read NODE_AUTH_TOKEN from the repository secret NPM_TOKEN. That credential began being rejected on 2026-08-17. From then until 2026-08-26 every daily release run reached the publish step and failed with npm code E404 on PUT to the registry, which is how npm reports a rejected write credential rather than a missing package. Rotating the secret on 2026-08-22 did not help, and the copy of the credential on the maintainer host answers 401 to npm whoami, so the token is dead rather than mis-stored. Because the version bump and the release commit both land before the publish step, main advanced to 2026.8.18 with no matching tag and no published artifact while npm still serves 2026.8.18. Sixteen of the eighteen published fleet packages are in the same state."},{"op":"add","path":"/metadata/type","value":"Issue"},{"op":"add","path":"/metadata/status","value":"in_progress"},{"op":"add","path":"/metadata/priority","value":0},{"op":"add","path":"/metadata/tags","value":["npm","release","supply-chain"]},{"op":"add","path":"/metadata/created_at","value":"2026-08-26T19:33:40.186Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-08-26T19:33:40.186Z"},{"op":"add","path":"/metadata/deadline","value":"2026-09-02T00:00:00.000Z"},{"op":"add","path":"/metadata/assignee","value":"claude"},{"op":"add","path":"/metadata/author","value":"claude"},{"op":"add","path":"/metadata/estimated_minutes","value":90},{"op":"add","path":"/metadata/acceptance_criteria","value":"No release workflow references NODE_AUTH_TOKEN NPM_TOKEN or secrets.NPM outside a comment; the publish job carries id-token write; npm is raised to at least 11.5.1 before the publish step because the npm bundled with node 22 cannot exchange an OIDC token; a test fails closed if a stored token is reintroduced or the npm upgrade is removed"},{"op":"add","path":"/metadata/comments","value":[{"created_at":"2026-08-26T19:33:40.186Z","author":"claude","text":"Guards verified as non-vacuous: reintroducing NODE_AUTH_TOKEN fails the OIDC test and deleting the npm upgrade step fails the npm-version test, while the unmodified tree passes both."}]},{"op":"add","path":"/metadata/notes","value":[{"created_at":"2026-08-26T19:33:40.186Z","author":"claude","text":"Root cause was found by comparing npm view against the branch, not by reading CI. Every job except publish was green for ten days."}]},{"op":"add","path":"/metadata/learnings","value":[{"created_at":"2026-08-26T19:33:40.186Z","author":"claude","text":"A release pipeline that bumps and commits the version before it publishes hides a credential outage indefinitely. Registry state, not workflow state, is the definition of released."}]},{"op":"add","path":"/metadata/files","value":[{"path":".github/workflows/release.yml","scope":"project"}]},{"op":"add","path":"/metadata/tests","value":[{"command":"npx tsx --test test/release-workflow.test.ts","scope":"project"}]},{"op":"add","path":"/metadata/docs","value":[{"path":"https://docs.npmjs.com/trusted-publishers","scope":"project","note":"npm trusted publishing setup"}]}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"4a18fb50a710db67eeedf85a8591f5864248be8eaa39c8c2a0f9bf6304bed33e","item_hash_version":2,"message":"Record the npm credential outage and migrate this package to trusted publishing","context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"ts":"2026-08-26T23:06:20.627Z","author":"codex","author_source":"configured","agent_harness":"claude-code","agent_instance":"66f51baccd944ab11404f28c","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-26T23:06:20.627Z"},{"op":"replace","path":"/metadata/description","value":"The Publish npm package step read NODE_AUTH_TOKEN from the repository secret NPM_TOKEN. That credential began being rejected on 2026-08-17 UTC. From then until 2026-08-26 UTC every daily release run reached the publish step and failed with npm code E404 on PUT to the registry, which is how npm reports a rejected write credential rather than a missing package. Rotating the secret on 2026-08-22 UTC did not help, and the copy of the credential on the maintainer host answers 401 to npm whoami, so the token is dead rather than mis-stored. Because the version bump and the release commit both land before the publish step, main advanced to 2026.8.18 with no matching tag and no published artifact while npm still serves 2026.8.18. Sixteen of the eighteen published fleet packages are in the same state."}],"before_hash":"4a18fb50a710db67eeedf85a8591f5864248be8eaa39c8c2a0f9bf6304bed33e","after_hash":"3c88f3bf5e2161a0773e5ebda180b96d791fac742ebc0244f6379eeebb6ca152","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} diff --git a/.agents/pm/history/pm-github-yq1d.jsonl b/.agents/pm/history/pm-github-yq1d.jsonl new file mode 100644 index 0000000..73a3691 --- /dev/null +++ b/.agents/pm/history/pm-github-yq1d.jsonl @@ -0,0 +1,21 @@ +{"ts":"2026-08-26T21:04:46.029Z","author":"codex","author_source":"configured","agent_harness":"claude-code","agent_instance":"66f51baccd944ab11404f28c","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-github-yq1d"},{"op":"add","path":"/metadata/title","value":"A release run bumped and committed a new version every night while publishing was impossible, because the only externally-failable step runs last"},{"op":"add","path":"/metadata/description","value":"The release job orders the version bump and the release commit before the publish step. When the npm credential died on 2026-08-17 that ordering meant every nightly run advanced main to a fresh version, pushed no tag, published nothing, and still reported the bump as progress. The outage stayed invisible for ten days across sixteen packages. A probe against npm's OIDC exchange endpoint from a real workflow returned HTTP 404 with the message 'OIDC token exchange error - package not found', proving no trusted publisher is bound to the package, so publication cannot succeed until that is configured on npmjs.com."},{"op":"add","path":"/metadata/type","value":"Issue"},{"op":"add","path":"/metadata/status","value":"open"},{"op":"add","path":"/metadata/priority","value":0},{"op":"add","path":"/metadata/tags","value":["ci","npm","release","supply-chain"]},{"op":"add","path":"/metadata/created_at","value":"2026-08-26T21:04:46.029Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-08-26T21:04:46.029Z"},{"op":"add","path":"/metadata/author","value":"codex"}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"77ef7f75d0f58642604a1ceffa0192c18283afa78a223ef890b191faa0abe4f9","item_hash_version":2,"message":"","context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"ts":"2026-08-26T21:04:46.768Z","author":"codex","author_source":"configured","agent_harness":"claude-code","agent_instance":"66f51baccd944ab11404f28c","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-26T21:04:46.768Z"},{"op":"replace","path":"/metadata/status","value":"in_progress"},{"op":"add","path":"/metadata/acceptance_criteria","value":"The release run fails before the version bump when npm will not accept the workflow's OIDC identity; the failure message names the package, organization, repository and workflow filename to configure; the preflight cannot be made advisory with continue-on-error; reverting the preflight, the exact npm pin, the setup-node credential strip, or introducing any secret into the publish step each fail at least one test"},{"op":"add","path":"/metadata/learnings","value":[{"created_at":"2026-08-26T21:04:46.755Z","author":"codex","text":"Ordering, not the credential, is what made this a ten-day outage. The credential failure was ordinary; what made it invisible was that the only step which can fail for a reason outside the repository ran after the steps that mutate state. Any pipeline with that shape reports progress while achieving nothing, so the externally-failable check belongs first."}]},{"op":"add","path":"/metadata/files","value":[{"path":".github/workflows/release.yml","scope":"project"},{"path":"test/release-workflow.test.ts","scope":"project"}]}],"before_hash":"77ef7f75d0f58642604a1ceffa0192c18283afa78a223ef890b191faa0abe4f9","after_hash":"b9bd2f8491b7fdef9537ef34565ad9b20e8381cea9b22d90e3cec17d536ce54c","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"ts":"2026-08-26T21:44:32.306Z","author":"codex","author_source":"configured","agent_harness":"claude-code","agent_instance":"66f51baccd944ab11404f28c","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-26T21:44:32.306Z"},{"op":"add","path":"/metadata/comments","value":[{"created_at":"2026-08-26T21:44:32.306Z","author":"codex","text":"Verified by mutation rather than inspection. Twenty reverts were applied to the workflow one at a time and the guard suite re-run against each, and all twenty fail: reintroducing NODE_AUTH_TOKEN, deleting the preflight, marking it continue-on-error, loosening the npm pin to a caret range, dropping the setup-node _authToken strip, smuggling PUBLISH_CREDENTIAL or NODE_AUTH_TOKEN into the publish env, a --global npm downgrade, an _auth line, a username line, a keyfile line, NPM_CONFIG__AUTH in the publish env, a comment-only npm install, a job-level permissions block without id-token, an inline permissions mapping without it, a scalar permissions shorthand, id-token granted to a different job only, a fixed /tmp credential path, mktemp without the cleanup trap, and a bracket-form secret reference."}]}],"before_hash":"b9bd2f8491b7fdef9537ef34565ad9b20e8381cea9b22d90e3cec17d536ce54c","after_hash":"219459ebee2f37e0620d5d54bc216440e0237d633aa21697244a174e6ea496cc","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"ts":"2026-08-26T21:44:32.748Z","author":"codex","author_source":"configured","agent_harness":"claude-code","agent_instance":"66f51baccd944ab11404f28c","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/1","value":{"created_at":"2026-08-26T21:44:32.748Z","author":"codex","text":"Root cause was established by asking the registry, not by reading CI. A throwaway workflow minted a GitHub OIDC id-token of 1982 bytes and offered it directly to npm's exchange endpoint, which answered HTTP 404 with 'OIDC token exchange error - package not found'. That is npm reporting no trusted publisher binding rather than a missing package, since the package is on the registry. It also rules out the competing hypothesis that setup-node's registry-url writes an empty _authToken that short-circuits the exchange; that is handled defensively but was not the cause. Publishing stays blocked until a trusted publisher is configured on npmjs.com, which is outside this repository."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-26T21:44:32.748Z"}],"before_hash":"219459ebee2f37e0620d5d54bc216440e0237d633aa21697244a174e6ea496cc","after_hash":"24135ad1f041301ad62c5f073c6a52139d7d7870b67fb0595f11047e231d8a48","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"ts":"2026-08-26T21:44:33.218Z","author":"codex","author_source":"configured","agent_harness":"claude-code","agent_instance":"66f51baccd944ab11404f28c","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/2","value":{"created_at":"2026-08-26T21:44:33.218Z","author":"codex","text":"Review found a real credential leak in the preflight and it is fixed. On the HTTP 200 path the exchange response body IS a short-lived npm publish credential. It was written to a predictable path and left there for the rest of the job, where every later step running as the same runner user could read it; not echoing it was not sufficient. It now goes to an mktemp file removed by an EXIT trap on every path, and the error branch reads that file through the environment rather than by hardcoded name. The first attempt at that error branch wrote the env assignment after the node invocation, which passes argv rather than environment and would have silently produced an empty reason string; that was caught by running the line rather than assuming it."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-26T21:44:33.218Z"}],"before_hash":"24135ad1f041301ad62c5f073c6a52139d7d7870b67fb0595f11047e231d8a48","after_hash":"920269104ac39d4e5494471cb22d83460ab794d475ea9e262e0204b2ad96a987","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"ts":"2026-08-26T22:04:57.200Z","author":"codex","author_source":"configured","agent_harness":"claude-code","agent_instance":"66f51baccd944ab11404f28c","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/3","value":{"created_at":"2026-08-26T22:04:57.200Z","author":"codex","text":"Round-4 review found a defect that would have blocked every correct release, which is worse than the outage the preflight exists to prevent. The exchange URL was built from the raw package.json name, so a scoped name such as @unbrained/pm-web addressed a different path entirely instead of %40unbrained%2Fpm-web; and only HTTP 200 was accepted while npm answers 201 on a successful exchange. Both are fixed: the name is encoded with encodeURIComponent, and any 2xx is accepted. Two bypasses were also closed: the preflight's if: could be changed to false, leaving the step present, correctly ordered and never executed while the bump commit and publish steps still ran, so the exact release condition is now pinned and must match the one the mutating steps use; and a second trap EXIT silently replaces the first, so exactly one trap is now permitted. Four more reverts verified, twenty-four in total."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-26T22:04:57.200Z"}],"before_hash":"920269104ac39d4e5494471cb22d83460ab794d475ea9e262e0204b2ad96a987","after_hash":"2874910d4595177bd0cf7488de54efdd82e2a8f0ca043bf61602c3d1c7251ed4","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"ts":"2026-08-26T22:11:55.609Z","author":"codex","author_source":"configured","agent_harness":"claude-code","agent_instance":"66f51baccd944ab11404f28c","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/4","value":{"created_at":"2026-08-26T22:11:55.609Z","author":"codex","text":"Round-5 review closed eight more bypasses. The credential scrub deleted only the token spelling, leaving basic auth, the legacy username and password pair and the mTLS pair in the userconfig for npm to use instead of the exchange. The guards missed a global flag written before the subcommand (npm -g install npm@10), the lowercase npm_config_ environment family, npm set as distinct from npm config set, space-separated auth options where key and value are not joined by an equals sign, and a permission line downgraded behind a trailing comment. Both preflight curls were unbounded, so a hung registry would hang a job that is holding an id-token rather than failing it. And a registry outage was reported as an identity refusal, which would have sent a maintainer to reconfigure a trusted publisher that was already correct; 000 and 5xx now fail with their own message. Eight more reverts verified, thirty-two in total."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-26T22:11:55.609Z"}],"before_hash":"2874910d4595177bd0cf7488de54efdd82e2a8f0ca043bf61602c3d1c7251ed4","after_hash":"1085a2f0c5a133bef557ec2abd5958be21e4c0cdeb6d0ad16250c4ea4da6adb4","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"ts":"2026-08-26T22:15:30.298Z","author":"codex","author_source":"configured","agent_harness":"claude-code","agent_instance":"66f51baccd944ab11404f28c","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-26T22:15:30.298Z"},{"op":"replace","path":"/metadata/description","value":"The release job orders the version bump and the release commit before the publish step. When the npm credential died on 2026-08-17 UTC that ordering meant every nightly run advanced main to a fresh version, pushed no tag, published nothing, and still reported the bump as progress. The outage stayed invisible for ten days across sixteen packages. A probe against npm's OIDC exchange endpoint from a real workflow returned HTTP 404 with the message 'OIDC token exchange error - package not found', proving no trusted publisher is bound to the package, so publication cannot succeed until that is configured on npmjs.com. All dates in this record are UTC, matching the metadata timestamps."}],"before_hash":"1085a2f0c5a133bef557ec2abd5958be21e4c0cdeb6d0ad16250c4ea4da6adb4","after_hash":"58f08fcbbec9ae25538d2072cbf227473599ffd89affb352afcdc872dfce51c7","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"ts":"2026-08-26T23:07:43.050Z","author":"codex","author_source":"configured","agent_harness":"claude-code","agent_instance":"66f51baccd944ab11404f28c","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/5","value":{"created_at":"2026-08-26T23:07:43.050Z","author":"codex","text":"Round-6 review was right and my earlier pushback was wrong. Check release ref refused a non-main ref only AFTER the OIDC preflight, the version bump and the release commit had already run. A workflow_dispatch from a feature branch would therefore mint an id-token, exchange it for a short-lived npm publish credential, mutate the repository, and only then be refused - obtaining a credential the run is forbidden to use. I had replied to this finding arguing the ordering was immaterial because both steps precede publication; that missed the point, which is that requesting a credential is itself an action. The ref check now runs before the preflight, and the guard asserts that ordering; moving it back after fails the suite."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-26T23:07:43.050Z"}],"before_hash":"58f08fcbbec9ae25538d2072cbf227473599ffd89affb352afcdc872dfce51c7","after_hash":"a7ba56ba1274296bd0bda16fe9980580eafe02b73340dfa19de0ffc905763dbd","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"ts":"2026-08-26T23:07:43.646Z","author":"codex","author_source":"configured","agent_harness":"claude-code","agent_instance":"66f51baccd944ab11404f28c","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"update","patch":[{"op":"add","path":"/metadata/learnings/1","value":{"created_at":"2026-08-26T23:07:43.620Z","author":"codex","text":"A near-miss worth keeping. A fleet-wide script read each item description, transformed it, and wrote it back. The transform used a sed lookahead, which sed does not support, so sed failed and the shell substitution produced an empty string - which was then written as the new description on eighteen items across eighteen repositories. Nothing warned, because an empty description is a valid value. The changes were uncommitted so git restored every one. The rule that would have prevented it: a transform that writes back must verify the result is non-empty and not shorter than the input before writing, and must abort rather than write when it is."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-26T23:07:43.646Z"}],"before_hash":"a7ba56ba1274296bd0bda16fe9980580eafe02b73340dfa19de0ffc905763dbd","after_hash":"3e438eb36ccce58ffb7877e9479b147f06f8618048cefa931ad77ec0dcbd51fd","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"ts":"2026-08-26T23:26:42.813Z","author":"codex","author_source":"configured","agent_harness":"claude-code","agent_instance":"66f51baccd944ab11404f28c","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"update","patch":[{"op":"remove","path":"/metadata/learnings"},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-26T23:26:42.813Z"}],"before_hash":"3e438eb36ccce58ffb7877e9479b147f06f8618048cefa931ad77ec0dcbd51fd","after_hash":"29b741b9adb0d3b856cd1199cd819785f2b83be7da8cb9111764db19406ffb80","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"ts":"2026-08-26T23:26:43.248Z","author":"codex","author_source":"configured","agent_harness":"claude-code","agent_instance":"66f51baccd944ab11404f28c","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/acceptance_criteria","value":"The release job is gated by ref at the job level so no repository-controlled code runs with id-token write from a non-main ref; the run fails before the version bump when npm will not accept the workflow's OIDC identity, naming the package, organization, repository and workflow to configure; a registry outage is reported as a registry outage rather than an identity refusal; no registry credential reaches the publish step under any name or mechanism, covering _authToken, _auth, username, _password, certfile, keyfile, npm login, npm set, npm config set, the NPM_CONFIG_ environment family in either case, and any secrets reference in the publish step; every credential the scrub claims to remove is removed; the effective npm is verified at install time and again immediately before publication; and each of the forty-one enumerated reverts fails at least one test."},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-26T23:26:43.248Z"},{"op":"add","path":"/metadata/learnings","value":[{"created_at":"2026-08-26T23:26:43.231Z","author":"codex","text":"Ordering, not the credential, is what made this a ten-day outage. The credential failure was ordinary; what made it invisible was that the only step which can fail for a reason outside the repository ran after the steps that mutate state. Any pipeline with that shape reports progress while achieving nothing, so the externally-failable check belongs first."},{"created_at":"2026-08-26T23:26:43.231Z","author":"codex","text":"Requesting a credential is an action, not a read, and belongs after every refusal that could reject the run. A gate ordered to fail before mutation is not automatically ordered to fail before acquisition; those are two different orderings, and only one of them was right here. The job-level ref gate matters for the same reason: npm ci runs the checked-out package's prepare hook with id-token write held, before any step-level refusal can fire."}]}],"before_hash":"29b741b9adb0d3b856cd1199cd819785f2b83be7da8cb9111764db19406ffb80","after_hash":"377ef4b35c03c897a9c68c6d6c0930939be1d094225e8d91c1c9be2acf53ceeb","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"ts":"2026-08-26T23:27:52.598Z","author":"codex","author_source":"configured","agent_harness":"claude-code","agent_instance":"66f51baccd944ab11404f28c","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/6","value":{"created_at":"2026-08-26T23:27:52.598Z","author":"codex","text":"Round-7 review found three privilege and reachability defects. The job inherited id-token write and npm ci runs the checked-out package's prepare hook, so a workflow_dispatch from a feature ref executed repository-controlled code with release privileges before any step-level refusal could fire; the job is gated by ref now, with the step check kept as defence in depth. The npm version was verified only at install time, so a later step prepending a directory to GITHUB_PATH could change which npm published; the effective version is re-verified immediately before publication and GITHUB_PATH writes between the two are rejected. And the preflight's own failure handling was unreachable: under set -u a bare ACTIONS_ID_TOKEN_ reference aborts with unbound variable before the diagnosis prints, and under set -e a non-zero curl aborts before the registry-outage classification runs, so both requests now capture their status. Also fixed my own credential filter, which deleted whole sed lines and would have hidden a same-line credential restore, and the scrub assertions, which read raw source and passed against the step's own comment text. Four more reverts verified, forty-one in total."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-26T23:27:52.598Z"}],"before_hash":"377ef4b35c03c897a9c68c6d6c0930939be1d094225e8d91c1c9be2acf53ceeb","after_hash":"94801bf7276fe25a999cee2b2d389e4019b101b570e1fb8a303566d05c35e560","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"ts":"2026-08-27T04:11:47.334Z","author":"codex","author_source":"configured","agent_harness":"claude-code","agent_instance":"66f51baccd944ab11404f28c","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/7","value":{"created_at":"2026-08-27T04:11:47.334Z","author":"codex","text":"Maintainer decision on the reachable-history privacy issue, taken 2026-08-27 UTC: freeze the past, gate the future. The measured exposure contains no keys, tokens, private addresses or credentials - GitHub secret scanning reports zero alerts and tracked files are clean. What remains is a small number of machine-local commit identities in older history, and absolute home paths that survive only inside .agents/pm prose, including records that describe removing them. Rewriting that history would orphan release tags across the fleet, break the provenance chain of already-published npm versions, and invalidate every existing clone, which the maintainer judged to exceed the benefit of removing a Linux username from old commit metadata. A forward gate is added instead: test/identity-audit.test.ts refuses any commit after a recorded baseline that carries an unapproved author or committer identity, or that adds an absolute home path in any file including pm prose. Verified by violation rather than by inspection: an unapproved-identity commit, a commit adding an absolute home path, and a baseline the checkout does not contain each fail the suite, and the missing-baseline case fails all three tests so a shallow clone cannot make the gate silently pass."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-27T04:11:47.334Z"}],"before_hash":"94801bf7276fe25a999cee2b2d389e4019b101b570e1fb8a303566d05c35e560","after_hash":"6397033a61f178f98eb178415b02604091e4f6b3129232eea43b0660f03b41e4","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"ts":"2026-08-27T04:39:59.499Z","author":"codex","author_source":"configured","agent_harness":"claude-code","agent_instance":"66f51baccd944ab11404f28c","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/8","value":{"created_at":"2026-08-27T04:39:59.499Z","author":"codex","text":"Round-8 review found a guard of mine that was vacuous and I had reported as verified. Asserting publish.includes('_auth') is satisfied by the _authToken deletion expression alone, so the basic-auth scrub could be deleted outright while the loop still passed; my earlier mutation had removed all five expressions at once and so never exposed it. Each key is now asserted in the delimited form the scrub actually uses, and removing any single expression fails. Also fixed: the npm substitution window stopped at the start of the publish step, so an install placed after the publish-time version gate and before npm publish passed everything; 429 was classed as an identity refusal rather than rate limiting; the exchange URL used encodeURIComponent, which percent-encodes the leading at-sign, while npm's escapedName preserves it and encodes only the separator; and four run scripts interpolated workflow context directly into privileged shell commands, including repository-controlled metadata, which now reaches them through env instead."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-27T04:39:59.499Z"}],"before_hash":"6397033a61f178f98eb178415b02604091e4f6b3129232eea43b0660f03b41e4","after_hash":"f0164bbecfe43b61f4723f401e47d7d9afe33187baf03dfe0825d87aef2de133","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"ts":"2026-08-27T04:46:19.395Z","author":"codex","author_source":"configured","agent_harness":"claude-code","agent_instance":"66f51baccd944ab11404f28c","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/acceptance_criteria","value":"The release job is gated by ref at the job level so no repository-controlled code runs with id-token write from a non-main ref; the run fails before the version bump when npm will not accept the workflow's OIDC identity, naming the package, organization, repository and workflow to configure; a registry outage or rate limit is reported as such rather than as an identity refusal; no registry credential reaches the publish step under any name or mechanism; every credential the scrub claims to remove is removed, asserted in the delimited form so no key's expression can satisfy another's; the effective npm is verified at install time and again immediately before publication; no run script in the release job interpolates workflow context; and all forty-two enumerated mutations fail the guard suite."},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-27T04:46:19.395Z"}],"before_hash":"f0164bbecfe43b61f4723f401e47d7d9afe33187baf03dfe0825d87aef2de133","after_hash":"3947a42e3ea6fa3b2bb203cca8fb7ef1dbc40115e601a4e4dd3f6739dc6ea8b7","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"ts":"2026-08-27T04:46:19.824Z","author":"codex","author_source":"configured","agent_harness":"claude-code","agent_instance":"66f51baccd944ab11404f28c","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/9","value":{"created_at":"2026-08-27T04:46:19.824Z","author":"codex","text":"Correcting my own arithmetic, which review caught. I had been carrying a running revert tally in prose and it drifted: the increments did not sum to the totals I quoted, and one of the reverts I had reported as verified was in fact vacuous. Replaced with a measured number rather than a maintained one. The mutation set is now enumerated explicitly and re-run as a suite: forty-two distinct mutations of the release workflow, covering credential reintroduction under every name and mechanism, npm substitution before and inside the publish step, preflight deletion and disabling, permission overrides in three spellings, credential-file handling, transient-versus-refusal classification, scoped-name encoding, and workflow-context interpolation. Result: forty-two applied, forty-two caught, zero missed."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-27T04:46:19.824Z"}],"before_hash":"3947a42e3ea6fa3b2bb203cca8fb7ef1dbc40115e601a4e4dd3f6739dc6ea8b7","after_hash":"704bb172064f94f6cf72b8fd5ad4d8ae1dbee6cfad9252c10085c169d4377455","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"ts":"2026-08-27T05:06:47.622Z","author":"codex","author_source":"configured","agent_harness":"claude-code","agent_instance":"66f51baccd944ab11404f28c","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/acceptance_criteria","value":"The release job is gated by ref at the job level so no repository-controlled code runs with id-token write from a non-main ref; the run fails before the version bump when npm will not accept the workflow's OIDC identity, naming the package, organization, repository and workflow to configure; a registry outage or rate limit is reported as such rather than as an identity refusal; no registry credential reaches the publish step under any name or mechanism; every credential the scrub claims to remove is removed, asserted in the delimited form so no key's expression can satisfy another's; the effective npm is verified at install time and again immediately before publication; no run script in the release job interpolates workflow context; and all forty-eight enumerated mutations fail the guard suite."},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-27T05:06:47.622Z"}],"before_hash":"704bb172064f94f6cf72b8fd5ad4d8ae1dbee6cfad9252c10085c169d4377455","after_hash":"14c058157ecdef7685c9acff8794e1f75e8e4b7c9447bfdf7f7fa1dc8948c4d5","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"ts":"2026-08-27T05:06:48.079Z","author":"codex","author_source":"configured","agent_harness":"claude-code","agent_instance":"66f51baccd944ab11404f28c","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/10","value":{"created_at":"2026-08-27T05:06:48.079Z","author":"codex","text":"Round-9 review found that a GLOBAL credential evaded every guard. npm honours an unscoped credential, so 'npm config set _auth ' - no registry scope, no equals sign, space-separated - passed the delimited-key checks, the colon-prefixed check and the //registry check alike, and the scrub removed only the registry-scoped forms, so legacy authentication stayed configured while the no-credential guard reported clean. The scrub now removes both the scoped and the global spelling of every credential key, and the guard rejects 'npm set' and 'npm config set' of a credential key with or without a registry scope. Five more mutations verified; the enumerated set is now forty-eight applied, forty-eight caught, none missed."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-27T05:06:48.079Z"}],"before_hash":"14c058157ecdef7685c9acff8794e1f75e8e4b7c9447bfdf7f7fa1dc8948c4d5","after_hash":"33fb0fb7ad791958e41bc74adb1dd44a545b1cdef7e5c6e199d868094e2f342e","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"ts":"2026-08-27T10:30:17.427Z","author":"codex","author_source":"configured","agent_harness":"claude-code","agent_instance":"66f51baccd944ab11404f28c","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/11","value":{"created_at":"2026-08-27T10:30:17.427Z","author":"codex","text":"Round-10 review found the follow-on I had asked reviewers to look for, and it was worse than the previous one. An intervening flag defeats the guard - npm config set --global _auth , or --location=global - because the pattern required the credential key immediately after set. And a global credential is written to npm's GLOBAL config, not the userconfig, so the publish-step scrub could not have removed it even if the guard had caught it. Both are closed: the guard tolerates any number of flags between set and the key, and the scrub now iterates over the userconfig and the path reported by npm config get globalconfig. Three more mutations verified. The enumerated set is fifty-one applied, fifty-one caught, none missed. Twice now a credential spelling has slipped past a guard I described as by-mechanism rather than by-name, which is worth recording: the mechanism I was enumerating was the FILE FORMAT, and the thing that kept escaping was the COMMAND SURFACE that writes it."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-27T10:30:17.427Z"}],"before_hash":"33fb0fb7ad791958e41bc74adb1dd44a545b1cdef7e5c6e199d868094e2f342e","after_hash":"76bf84aa4a01a8214e6ed23c1c90528078b47e20709a367457a92ba779034911","item_hash_version":2,"context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} +{"ts":"2026-08-27T18:52:32.800Z","author":"claude-agent","author_source":"asserted","agent_harness":"claude-code","agent_instance":"74d2f6d55073a15471e92197","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/acceptance_criteria","value":"The release job is gated by ref at the job level so no repository-controlled code runs with id-token write from a non-main ref; the run fails before the version bump when npm will not accept the workflow's OIDC identity, naming the package, organization, repository and workflow to configure; a registry outage or rate limit is reported as such rather than as an identity refusal; no registry credential reaches the publish step under any name or mechanism; every credential the scrub claims to remove is removed, asserted in the delimited form so no key's expression can satisfy another's; the effective npm is verified at install time and again immediately before publication; no run script in the release job interpolates workflow context; and every enumerated mutation fails the guard suite, with the verified count recorded on the run that produced it rather than fixed in this criterion."},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-27T18:52:32.800Z"}],"before_hash":"76bf84aa4a01a8214e6ed23c1c90528078b47e20709a367457a92ba779034911","after_hash":"66f370719631f72092e7d922fc139dfe6eebb217a28140bdd1bd73a0bda0dced","item_hash_version":2,"message":"State the mutation property instead of a tally that goes stale whenever a mutation is added","context":{"agent_provenance_outcomes":{"model":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"},"version":{"status":"failed","reason":"resolver_failed","resolver":"claude_session_file","rule_version":"v1"}}}} diff --git a/.agents/pm/issues/pm-github-m8u2.toon b/.agents/pm/issues/pm-github-m8u2.toon new file mode 100644 index 0000000..86798f5 --- /dev/null +++ b/.agents/pm/issues/pm-github-m8u2.toon @@ -0,0 +1,27 @@ +id: pm-github-m8u2 +title: "The release job authenticated with a stored npm token that expired, so publishing stopped while every other gate stayed green" +description: "The Publish npm package step read NODE_AUTH_TOKEN from the repository secret NPM_TOKEN. That credential began being rejected on 2026-08-17 UTC. From then until 2026-08-26 UTC every daily release run reached the publish step and failed with npm code E404 on PUT to the registry, which is how npm reports a rejected write credential rather than a missing package. Rotating the secret on 2026-08-22 UTC did not help, and the copy of the credential on the maintainer host answers 401 to npm whoami, so the token is dead rather than mis-stored. Because the version bump and the release commit both land before the publish step, main advanced to 2026.8.18 with no matching tag and no published artifact while npm still serves 2026.8.18. Sixteen of the eighteen published fleet packages are in the same state." +type: Issue +status: in_progress +priority: 0 +tags[3]: npm,release,supply-chain +created_at: "2026-08-26T19:33:40.186Z" +updated_at: "2026-08-26T23:06:20.627Z" +deadline: "2026-09-02T00:00:00.000Z" +assignee: claude +author: claude +estimated_minutes: 90 +acceptance_criteria: No release workflow references NODE_AUTH_TOKEN NPM_TOKEN or secrets.NPM outside a comment; the publish job carries id-token write; npm is raised to at least 11.5.1 before the publish step because the npm bundled with node 22 cannot exchange an OIDC token; a test fails closed if a stored token is reintroduced or the npm upgrade is removed +comments[1]{created_at,author,text}: + "2026-08-26T19:33:40.186Z",claude,"Guards verified as non-vacuous: reintroducing NODE_AUTH_TOKEN fails the OIDC test and deleting the npm upgrade step fails the npm-version test, while the unmodified tree passes both." +notes[1]{created_at,author,text}: + "2026-08-26T19:33:40.186Z",claude,"Root cause was found by comparing npm view against the branch, not by reading CI. Every job except publish was green for ten days." +learnings[1]{created_at,author,text}: + "2026-08-26T19:33:40.186Z",claude,"A release pipeline that bumps and commits the version before it publishes hides a credential outage indefinitely. Registry state, not workflow state, is the definition of released." +files[1]{path,scope}: + .github/workflows/release.yml,project +tests[1]{command,scope}: + npx tsx --test test/release-workflow.test.ts,project +docs[1]{path,scope,note}: + "https://docs.npmjs.com/trusted-publishers",project,npm trusted publishing setup +body: "Trusted publishing replaces the stored credential. The registry mints a short lived credential from the workflow OIDC identity, so there is no secret left to expire. Requires a one time configuration on npmjs.com binding this package to unbraind/pm-github and the release.yml workflow." diff --git a/.agents/pm/issues/pm-github-yq1d.toon b/.agents/pm/issues/pm-github-yq1d.toon new file mode 100644 index 0000000..0e09415 --- /dev/null +++ b/.agents/pm/issues/pm-github-yq1d.toon @@ -0,0 +1,31 @@ +id: pm-github-yq1d +title: "A release run bumped and committed a new version every night while publishing was impossible, because the only externally-failable step runs last" +description: "The release job orders the version bump and the release commit before the publish step. When the npm credential died on 2026-08-17 UTC that ordering meant every nightly run advanced main to a fresh version, pushed no tag, published nothing, and still reported the bump as progress. The outage stayed invisible for ten days across sixteen packages. A probe against npm's OIDC exchange endpoint from a real workflow returned HTTP 404 with the message 'OIDC token exchange error - package not found', proving no trusted publisher is bound to the package, so publication cannot succeed until that is configured on npmjs.com. All dates in this record are UTC, matching the metadata timestamps." +type: Issue +status: in_progress +priority: 0 +tags[4]: ci,npm,release,supply-chain +created_at: "2026-08-26T21:04:46.029Z" +updated_at: "2026-08-27T18:52:32.800Z" +author: codex +acceptance_criteria: "The release job is gated by ref at the job level so no repository-controlled code runs with id-token write from a non-main ref; the run fails before the version bump when npm will not accept the workflow's OIDC identity, naming the package, organization, repository and workflow to configure; a registry outage or rate limit is reported as such rather than as an identity refusal; no registry credential reaches the publish step under any name or mechanism; every credential the scrub claims to remove is removed, asserted in the delimited form so no key's expression can satisfy another's; the effective npm is verified at install time and again immediately before publication; no run script in the release job interpolates workflow context; and every enumerated mutation fails the guard suite, with the verified count recorded on the run that produced it rather than fixed in this criterion." +comments[12]{created_at,author,text}: + "2026-08-26T21:44:32.306Z",codex,"Verified by mutation rather than inspection. Twenty reverts were applied to the workflow one at a time and the guard suite re-run against each, and all twenty fail: reintroducing NODE_AUTH_TOKEN, deleting the preflight, marking it continue-on-error, loosening the npm pin to a caret range, dropping the setup-node _authToken strip, smuggling PUBLISH_CREDENTIAL or NODE_AUTH_TOKEN into the publish env, a --global npm downgrade, an _auth line, a username line, a keyfile line, NPM_CONFIG__AUTH in the publish env, a comment-only npm install, a job-level permissions block without id-token, an inline permissions mapping without it, a scalar permissions shorthand, id-token granted to a different job only, a fixed /tmp credential path, mktemp without the cleanup trap, and a bracket-form secret reference." + "2026-08-26T21:44:32.748Z",codex,"Root cause was established by asking the registry, not by reading CI. A throwaway workflow minted a GitHub OIDC id-token of 1982 bytes and offered it directly to npm's exchange endpoint, which answered HTTP 404 with 'OIDC token exchange error - package not found'. That is npm reporting no trusted publisher binding rather than a missing package, since the package is on the registry. It also rules out the competing hypothesis that setup-node's registry-url writes an empty _authToken that short-circuits the exchange; that is handled defensively but was not the cause. Publishing stays blocked until a trusted publisher is configured on npmjs.com, which is outside this repository." + "2026-08-26T21:44:33.218Z",codex,"Review found a real credential leak in the preflight and it is fixed. On the HTTP 200 path the exchange response body IS a short-lived npm publish credential. It was written to a predictable path and left there for the rest of the job, where every later step running as the same runner user could read it; not echoing it was not sufficient. It now goes to an mktemp file removed by an EXIT trap on every path, and the error branch reads that file through the environment rather than by hardcoded name. The first attempt at that error branch wrote the env assignment after the node invocation, which passes argv rather than environment and would have silently produced an empty reason string; that was caught by running the line rather than assuming it." + "2026-08-26T22:04:57.200Z",codex,"Round-4 review found a defect that would have blocked every correct release, which is worse than the outage the preflight exists to prevent. The exchange URL was built from the raw package.json name, so a scoped name such as @unbrained/pm-web addressed a different path entirely instead of %40unbrained%2Fpm-web; and only HTTP 200 was accepted while npm answers 201 on a successful exchange. Both are fixed: the name is encoded with encodeURIComponent, and any 2xx is accepted. Two bypasses were also closed: the preflight's if: could be changed to false, leaving the step present, correctly ordered and never executed while the bump commit and publish steps still ran, so the exact release condition is now pinned and must match the one the mutating steps use; and a second trap EXIT silently replaces the first, so exactly one trap is now permitted. Four more reverts verified, twenty-four in total." + "2026-08-26T22:11:55.609Z",codex,"Round-5 review closed eight more bypasses. The credential scrub deleted only the token spelling, leaving basic auth, the legacy username and password pair and the mTLS pair in the userconfig for npm to use instead of the exchange. The guards missed a global flag written before the subcommand (npm -g install npm@10), the lowercase npm_config_ environment family, npm set as distinct from npm config set, space-separated auth options where key and value are not joined by an equals sign, and a permission line downgraded behind a trailing comment. Both preflight curls were unbounded, so a hung registry would hang a job that is holding an id-token rather than failing it. And a registry outage was reported as an identity refusal, which would have sent a maintainer to reconfigure a trusted publisher that was already correct; 000 and 5xx now fail with their own message. Eight more reverts verified, thirty-two in total." + "2026-08-26T23:07:43.050Z",codex,"Round-6 review was right and my earlier pushback was wrong. Check release ref refused a non-main ref only AFTER the OIDC preflight, the version bump and the release commit had already run. A workflow_dispatch from a feature branch would therefore mint an id-token, exchange it for a short-lived npm publish credential, mutate the repository, and only then be refused - obtaining a credential the run is forbidden to use. I had replied to this finding arguing the ordering was immaterial because both steps precede publication; that missed the point, which is that requesting a credential is itself an action. The ref check now runs before the preflight, and the guard asserts that ordering; moving it back after fails the suite." + "2026-08-26T23:27:52.598Z",codex,"Round-7 review found three privilege and reachability defects. The job inherited id-token write and npm ci runs the checked-out package's prepare hook, so a workflow_dispatch from a feature ref executed repository-controlled code with release privileges before any step-level refusal could fire; the job is gated by ref now, with the step check kept as defence in depth. The npm version was verified only at install time, so a later step prepending a directory to GITHUB_PATH could change which npm published; the effective version is re-verified immediately before publication and GITHUB_PATH writes between the two are rejected. And the preflight's own failure handling was unreachable: under set -u a bare ACTIONS_ID_TOKEN_ reference aborts with unbound variable before the diagnosis prints, and under set -e a non-zero curl aborts before the registry-outage classification runs, so both requests now capture their status. Also fixed my own credential filter, which deleted whole sed lines and would have hidden a same-line credential restore, and the scrub assertions, which read raw source and passed against the step's own comment text. Four more reverts verified, forty-one in total." + "2026-08-27T04:11:47.334Z",codex,"Maintainer decision on the reachable-history privacy issue, taken 2026-08-27 UTC: freeze the past, gate the future. The measured exposure contains no keys, tokens, private addresses or credentials - GitHub secret scanning reports zero alerts and tracked files are clean. What remains is a small number of machine-local commit identities in older history, and absolute home paths that survive only inside .agents/pm prose, including records that describe removing them. Rewriting that history would orphan release tags across the fleet, break the provenance chain of already-published npm versions, and invalidate every existing clone, which the maintainer judged to exceed the benefit of removing a Linux username from old commit metadata. A forward gate is added instead: test/identity-audit.test.ts refuses any commit after a recorded baseline that carries an unapproved author or committer identity, or that adds an absolute home path in any file including pm prose. Verified by violation rather than by inspection: an unapproved-identity commit, a commit adding an absolute home path, and a baseline the checkout does not contain each fail the suite, and the missing-baseline case fails all three tests so a shallow clone cannot make the gate silently pass." + "2026-08-27T04:39:59.499Z",codex,"Round-8 review found a guard of mine that was vacuous and I had reported as verified. Asserting publish.includes('_auth') is satisfied by the _authToken deletion expression alone, so the basic-auth scrub could be deleted outright while the loop still passed; my earlier mutation had removed all five expressions at once and so never exposed it. Each key is now asserted in the delimited form the scrub actually uses, and removing any single expression fails. Also fixed: the npm substitution window stopped at the start of the publish step, so an install placed after the publish-time version gate and before npm publish passed everything; 429 was classed as an identity refusal rather than rate limiting; the exchange URL used encodeURIComponent, which percent-encodes the leading at-sign, while npm's escapedName preserves it and encodes only the separator; and four run scripts interpolated workflow context directly into privileged shell commands, including repository-controlled metadata, which now reaches them through env instead." + "2026-08-27T04:46:19.824Z",codex,"Correcting my own arithmetic, which review caught. I had been carrying a running revert tally in prose and it drifted: the increments did not sum to the totals I quoted, and one of the reverts I had reported as verified was in fact vacuous. Replaced with a measured number rather than a maintained one. The mutation set is now enumerated explicitly and re-run as a suite: forty-two distinct mutations of the release workflow, covering credential reintroduction under every name and mechanism, npm substitution before and inside the publish step, preflight deletion and disabling, permission overrides in three spellings, credential-file handling, transient-versus-refusal classification, scoped-name encoding, and workflow-context interpolation. Result: forty-two applied, forty-two caught, zero missed." + "2026-08-27T05:06:48.079Z",codex,"Round-9 review found that a GLOBAL credential evaded every guard. npm honours an unscoped credential, so 'npm config set _auth ' - no registry scope, no equals sign, space-separated - passed the delimited-key checks, the colon-prefixed check and the //registry check alike, and the scrub removed only the registry-scoped forms, so legacy authentication stayed configured while the no-credential guard reported clean. The scrub now removes both the scoped and the global spelling of every credential key, and the guard rejects 'npm set' and 'npm config set' of a credential key with or without a registry scope. Five more mutations verified; the enumerated set is now forty-eight applied, forty-eight caught, none missed." + "2026-08-27T10:30:17.427Z",codex,"Round-10 review found the follow-on I had asked reviewers to look for, and it was worse than the previous one. An intervening flag defeats the guard - npm config set --global _auth , or --location=global - because the pattern required the credential key immediately after set. And a global credential is written to npm's GLOBAL config, not the userconfig, so the publish-step scrub could not have removed it even if the guard had caught it. Both are closed: the guard tolerates any number of flags between set and the key, and the scrub now iterates over the userconfig and the path reported by npm config get globalconfig. Three more mutations verified. The enumerated set is fifty-one applied, fifty-one caught, none missed. Twice now a credential spelling has slipped past a guard I described as by-mechanism rather than by-name, which is worth recording: the mechanism I was enumerating was the FILE FORMAT, and the thing that kept escaping was the COMMAND SURFACE that writes it." +learnings[2]{created_at,author,text}: + "2026-08-26T23:26:43.231Z",codex,"Ordering, not the credential, is what made this a ten-day outage. The credential failure was ordinary; what made it invisible was that the only step which can fail for a reason outside the repository ran after the steps that mutate state. Any pipeline with that shape reports progress while achieving nothing, so the externally-failable check belongs first." + "2026-08-26T23:26:43.231Z",codex,"Requesting a credential is an action, not a read, and belongs after every refusal that could reject the run. A gate ordered to fail before mutation is not automatically ordered to fail before acquisition; those are two different orderings, and only one of them was right here. The job-level ref gate matters for the same reason: npm ci runs the checked-out package's prepare hook with id-token write held, before any step-level refusal can fire." +files[2]{path,scope}: + .github/workflows/release.yml,project + test/release-workflow.test.ts,project +body: "" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 46b5173..4591bec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,6 +20,13 @@ concurrency: jobs: release: + # Gated at the JOB level, not only by the `Check release ref` step below. + # `npm ci` runs the checked-out package's `prepare` hook, and every step - + # including that one - runs with this job's `id-token: write`. A + # workflow_dispatch from a feature ref would therefore execute + # repository-controlled code with release privileges before any step-level + # refusal could fire. The step check stays as defence in depth. + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest env: RELEASE_TIMEZONE: Europe/Vienna @@ -37,6 +44,28 @@ jobs: cache: npm registry-url: "https://registry.npmjs.org" + # node 22 ships npm 10.x, which has no trusted-publishing support at all and + # would fall back to token auth. 11.5.1 is the first npm that can exchange + # the workflow's OIDC id-token for a registry credential. + - name: Use an npm that supports trusted publishing + shell: bash + run: | + set -euo pipefail + npm install -g npm@11.19.0 + # Fail closed on the EFFECTIVE version, not on having run the install. + # Installing is not the same as running: a swallowed install error, a + # cached shim, or a later step selecting another npm all leave 10.x + # active. npm 10 has no OIDC support and would fall back to token + # auth, reproducing the exact E404 this migration exists to remove - + # and it would look like trusted publishing itself had failed. + active="$(npm --version)" + required="11.5.1" + if [ "$(printf '%s\n%s\n' "$active" "$required" | sort -V | head -n 1)" != "$required" ]; then + echo "::error::npm $active cannot exchange an OIDC token; $required or newer is required." + exit 1 + fi + echo "npm $active can exchange an OIDC token for a registry credential." + - name: Setup Bun uses: oven-sh/setup-bun@v2.2.0 @@ -94,12 +123,137 @@ jobs: echo "npm_version=$npm_version" >> "$GITHUB_OUTPUT" echo "base_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + # Refuse non-main release runs BEFORE publishing. `github.ref` is the + # authoritative workflow trigger ref: schedule -> refs/heads/main; + # workflow_dispatch from main -> refs/heads/main; workflow_dispatch from a + # feature branch -> refs/heads/ (refused here). Checking the + # trigger ref (rather than git topology) reliably distinguishes a feature + # branch from main, which commit-topology checks cannot do (a feature + # branch off main is also a descendant of origin/main). + # This runs FIRST, before the OIDC preflight, and that ordering is the + # point: a workflow_dispatch from a feature branch would otherwise mint an + # id-token and exchange it for a short-lived npm PUBLISH CREDENTIAL, and + # only then be refused - requesting a credential the run is not allowed to + # use. Refusing on the ref costs nothing and must come first. + - name: Check release ref + if: steps.decide.outputs.should_release == 'true' + shell: bash + env: + GITHUB_REF: ${{ github.ref }} + run: | + set -euo pipefail + if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then + echo "::error::Refusing to release from non-main ref ('$GITHUB_REF'). Run the release workflow from main." + exit 1 + fi + + # Publication is the only step that can fail for a reason outside this + # repository, and it runs LAST - after the version bump and the release + # commit have already landed on main. That ordering is what let a dead + # credential hide for ten days: every run advanced main to a new version, + # published nothing, and still reported the bump as progress. Asking the + # registry for a credential BEFORE anything is mutated turns that silent + # drift into an immediate, actionable failure. + - name: Verify npm will accept this workflow's OIDC identity + if: steps.decide.outputs.should_release == 'true' + shell: bash + run: | + set -euo pipefail + pkg_name="$(node -p "require('./package.json').name")" + # A scoped name contains characters that are not path-safe: the URL + # segment for @unbrained/pm-web is %40unbrained%2Fpm-web, and sending + # the raw name instead addresses a different path entirely. Unscoped + # names encode to themselves, so this is not a no-op only for scoped + # packages - it is simply correct for both. + # npm's escapedName contract is NOT encodeURIComponent: the registry + # preserves the leading `@` and encodes only the separator, so + # @unbrained/pm-web addresses @unbrained%2fpm-web. Percent-encoding the + # `@` as well produces a path the registry does not recognise. + pkg_path="$(PKG="${pkg_name}" node -p "process.env.PKG.replace('/', '%2f')")" + # Read defensively: under `set -u` an unset ACTIONS_ID_TOKEN_* aborts the + # step with a raw shell error before the diagnosis below can print, so + # the operator sees "unbound variable" instead of "the job needs + # id-token: write". The whole point of this step is a legible failure. + request_url="${ACTIONS_ID_TOKEN_REQUEST_URL:-}" + request_token="${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" + if [ -z "${request_url}" ] || [ -z "${request_token}" ]; then + echo "::error::GitHub exposed no OIDC token endpoint to this job. The release job needs 'id-token: write'." + exit 1 + fi + # `set -e` would abort on a non-zero curl before anything could be + # classified, so the exit status is captured instead of propagating. + if ! id_token_body="$(curl -sS --max-time 30 -H "Authorization: bearer ${request_token}" \ + "${request_url}&audience=npm:registry.npmjs.org")"; then + echo "::error::Could not reach GitHub's OIDC token endpoint. Nothing was bumped, committed or tagged; re-run when it is reachable." + exit 1 + fi + id_token="$(printf '%s' "${id_token_body}" | node -p "JSON.parse(require('node:fs').readFileSync(0,'utf8')).value" 2>/dev/null)" || id_token="" + if [ -z "${id_token}" ] || [ "${id_token}" = "undefined" ]; then + echo "::error::GitHub would not mint an OIDC id-token. The release job needs 'id-token: write'." + exit 1 + fi + # The response body carries a short-lived PUBLISH CREDENTIAL on success. + # It is never echoed, and it must not outlive this step either: every + # later step in this job runs as the same runner user and could read it + # off disk. mktemp keeps it out of a predictable path and the EXIT trap + # removes it on success, on failure, and on early return alike. + response="$(mktemp)" + trap 'rm -f "${response}"' EXIT + # A timeout, DNS failure or refused connection makes curl exit non-zero, + # and `set -e` would abort here - before the 000 classification below + # could tell a registry outage apart from an identity refusal. Capture + # the status instead, and treat "curl produced nothing" as 000. + if ! status="$(curl -sS --max-time 30 -o "${response}" -w '%{http_code}' \ + -X POST "https://registry.npmjs.org/-/npm/v1/oidc/token/exchange/package/${pkg_path}" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${id_token}")"; then + status="000" + fi + [ -n "${status}" ] || status="000" + # npm answers 201 Created on a successful exchange and 200 in some + # paths. Accepting only one of them would fail a release whose trusted + # publisher IS configured - a preflight that blocks correct releases is + # worse than the outage it exists to prevent, so accept any 2xx. + if [ "${status}" -ge 200 ] && [ "${status}" -lt 300 ]; then + echo "npm accepted this workflow's identity for ${pkg_name}." + # The exchange proves the workflow is a recognised trusted publisher. + # It does NOT prove the binding permits publishing: a configuration + # created on or after 2026-05-20 selects allowed actions, and one + # scoped to staging alone exchanges successfully and then fails at + # `npm publish` - after this run has already mutated main. Say so, + # so a failure there is not read as a preflight that lied. + echo "Note: this verifies identity, not the allowed action. The trusted publisher must have 'npm publish' selected." + exit 0 + fi + reason="$(RESPONSE_FILE="${response}" node -p "try{JSON.parse(require('node:fs').readFileSync(process.env.RESPONSE_FILE,'utf8')).message||''}catch(e){''}")" + # 000 is curl's "no HTTP response" (timeout, DNS, connection refused) + # and 5xx is the registry failing on its own account. Neither says + # anything about this workflow's identity, and telling a maintainer to + # configure a trusted publisher they already configured would send them + # somewhere useless. Fail either way - a release must not proceed on an + # unverified identity - but say which failure it was. + # 429 belongs here too: the registry is rate-limiting this caller, which + # says nothing about whether a trusted publisher is bound. Sending a + # maintainer to reconfigure a correct publisher is the wrong answer. + if [ "${status}" = "000" ] || [ "${status}" = "429" ] || [ "${status}" -ge 500 ]; then + echo "::error::Could not reach npm to verify this workflow's identity for ${pkg_name} (HTTP ${status}${reason:+: ${reason}})." + echo "::error::This is a registry or network failure, NOT a trusted-publisher problem. Nothing was bumped, committed or tagged; re-run when the registry is reachable." + exit 1 + fi + echo "::error::npm refused this workflow's OIDC identity for ${pkg_name} (HTTP ${status}${reason:+: ${reason}})." + echo "::error::Nothing has been bumped, committed or tagged - this run stopped before mutating anything." + echo "::error::Configure a trusted publisher on npmjs.com for ${pkg_name}: Settings -> Trusted Publisher -> GitHub Actions," + echo "::error::organization 'unbraind', repository '${GITHUB_REPOSITORY#*/}', workflow 'release.yml', no environment." + exit 1 + - name: Update release version if: steps.decide.outputs.should_release == 'true' + env: + NPM_VERSION: ${{ steps.decide.outputs.npm_version }} shell: bash run: | set -euo pipefail - npm version "${{ steps.decide.outputs.npm_version }}" --no-git-tag-version --allow-same-version + npm version "${NPM_VERSION}" --no-git-tag-version --allow-same-version node -e "const fs=require('node:fs');const version=JSON.parse(fs.readFileSync('package.json','utf8')).version;for(const file of ['manifest.json']){if(!fs.existsSync(file))continue;const json=JSON.parse(fs.readFileSync(file,'utf8'));json.version=version;fs.writeFileSync(file,JSON.stringify(json,null,2)+'\n');}if(fs.existsSync('index.ts')){const source=fs.readFileSync('index.ts','utf8');const next=source.replace(/version:\s*[\"'][^\"']+[\"']/,'version: \"'+version+'\"');if(next!==source)fs.writeFileSync('index.ts',next);}" npm run build @@ -118,6 +272,9 @@ jobs: - name: Commit release files if: steps.decide.outputs.should_release == 'true' + env: + REPO_NAME: ${{ github.event.repository.name }} + RELEASE_TAG: ${{ steps.decide.outputs.tag }} shell: bash run: | set -euo pipefail @@ -130,26 +287,7 @@ jobs: if git diff --cached --quiet; then echo "Release files are already current; tagging existing commit." else - git commit -m "Release ${{ github.event.repository.name }} ${{ steps.decide.outputs.tag }}" - fi - - # Refuse non-main release runs BEFORE publishing. `github.ref` is the - # authoritative workflow trigger ref: schedule -> refs/heads/main; - # workflow_dispatch from main -> refs/heads/main; workflow_dispatch from a - # feature branch -> refs/heads/ (refused here). Checking the - # trigger ref (rather than git topology) reliably distinguishes a feature - # branch from main, which commit-topology checks cannot do (a feature - # branch off main is also a descendant of origin/main). - - name: Check release ref - if: steps.decide.outputs.should_release == 'true' - shell: bash - env: - GITHUB_REF: ${{ github.ref }} - run: | - set -euo pipefail - if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then - echo "::error::Refusing to release from non-main ref ('$GITHUB_REF'). Run the release workflow from main." - exit 1 + git commit -m "Release ${REPO_NAME} ${RELEASE_TAG}" fi - name: Merge release metadata through protected PR @@ -415,14 +553,56 @@ jobs: fi fi + # Authentication is npm trusted publishing (OIDC), not a long-lived token. + # The registry mints a short-lived credential from this workflow's id-token, + # so no NODE_AUTH_TOKEN is set here on purpose: a stored token is the thing + # that expired and silently stopped every fleet package publishing between + # 2026-08-17 and 2026-08-26 while main kept bumping the version. Trusted + # publishing must be configured for this package on npmjs.com against + # unbraind/pm-github and this workflow filename, or publish fails closed. - name: Publish npm package if: steps.decide.outputs.should_release == 'true' shell: bash env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NPM_VERSION: ${{ steps.decide.outputs.npm_version }} run: | set -euo pipefail + # actions/setup-node's registry-url writes + # `//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}` into the npm + # userconfig. With no token in the environment that expands to an + # EMPTY credential, and npm treats a configured-but-empty token as + # legacy auth - which blocks the OIDC exchange outright and fails with + # the same registry 404 this migration exists to remove. Remove any + # such line before publishing so the only credential path left is OIDC. + # Both files npm reads for a registry credential. `--global` and + # `--location=global` write to the GLOBAL config, which the userconfig + # scrub never touches, so scrubbing only one leaves the other live. + globalconfig="$(npm config get globalconfig 2>/dev/null || true)" + for userconfig in "${NPM_CONFIG_USERCONFIG:-$HOME/.npmrc}" "${globalconfig}"; do + if [ -n "$userconfig" ] && [ -f "$userconfig" ]; then + # Both the registry-scoped forms (//registry/:_auth=...) and the + # GLOBAL forms (_auth=... at the start of a line). npm honours an + # unscoped credential too, so removing only the scoped ones leaves + # legacy authentication configured while every guard still passes. + sed -i'' -e '/_authToken/d' \ + -e '/^[[:space:]]*_auth[[:space:]]*=/d' -e '/:_auth[[:space:]]*=/d' \ + -e '/^[[:space:]]*username[[:space:]]*=/d' -e '/:username[[:space:]]*=/d' \ + -e '/^[[:space:]]*_password[[:space:]]*=/d' -e '/:_password[[:space:]]*=/d' \ + -e '/^[[:space:]]*certfile[[:space:]]*=/d' -e '/:certfile[[:space:]]*=/d' \ + -e '/^[[:space:]]*keyfile[[:space:]]*=/d' -e '/:keyfile[[:space:]]*=/d' \ + -e '/always-auth/d' "$userconfig" + fi + done + # Re-verify HERE, not only at install time. A later step can prepend a + # directory to GITHUB_PATH and change which npm this step resolves, and + # npm 10 cannot exchange an OIDC token - it would fall back to token + # auth and reproduce the exact E404 this migration removes. + active_npm="$(npm --version)" + required_npm="11.5.1" + if [ "$(printf '%s\n%s\n' "$active_npm" "$required_npm" | sort -V | head -n 1)" != "$required_npm" ]; then + echo "::error::npm $active_npm resolved at publish time cannot exchange an OIDC token; $required_npm or newer is required." + exit 1 + fi pkg_name="$(node -p "require('./package.json').name")" # Idempotence guard: if the version already resolves on the registry, # treat the publish as already done and exit 0. This is what lets an @@ -514,11 +694,13 @@ jobs: - name: Verify bun install of published package if: steps.decide.outputs.should_release == 'true' + env: + NPM_VERSION: ${{ steps.decide.outputs.npm_version }} shell: bash run: | set -euo pipefail pkg_name="$(node -p "require('./package.json').name")" - pkg_version="${{ steps.decide.outputs.npm_version }}" + pkg_version="${NPM_VERSION}" mkdir -p /tmp/bun-verify cd /tmp/bun-verify rm -rf node_modules bun.lockb package.json @@ -549,5 +731,7 @@ jobs: - name: Create GitHub release if: steps.decide.outputs.should_release == 'true' env: + REPO_NAME: ${{ github.event.repository.name }} + RELEASE_TAG: ${{ steps.decide.outputs.tag }} GH_TOKEN: ${{ github.token }} - run: gh release create "${{ steps.decide.outputs.tag }}" --title "${{ github.event.repository.name }} ${{ steps.decide.outputs.tag }}" --notes-file RELEASE_NOTES.md --verify-tag + run: gh release create "${RELEASE_TAG}" --title "${REPO_NAME} ${RELEASE_TAG}" --notes-file RELEASE_NOTES.md --verify-tag diff --git a/.gitignore b/.gitignore index 38c6091..44593b9 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,9 @@ dist-test/ .agents/pm/search/ .agents/pm/extensions/ coverage/ + +# SDK workspace-transaction journals are runtime state, not tracked data. +# pm health fails closed on these (tracked_runtime_cache_files), so committing +# one turns the health gate red; the journal has no value once the transaction +# has landed. Deliberately outside any `pm-cli:` fence, which the CLI rewrites. +.agents/pm/transactions/ diff --git a/test/release-workflow.test.ts b/test/release-workflow.test.ts new file mode 100644 index 0000000..201d02f --- /dev/null +++ b/test/release-workflow.test.ts @@ -0,0 +1,488 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import test from "node:test"; + +/** The release workflow source, read once and asserted against as text. */ +const workflow = readFileSync( + resolve(import.meta.dirname, "../.github/workflows/release.yml"), + "utf-8" +); + +/** + * Locate a named workflow step so tests can assert on ordering between steps. + * + * Offsets are taken against the raw source rather than a comment-stripped copy: + * removing text shifts every index after it, which would silently corrupt the + * ordering comparisons these offsets exist to support. + * + * @param name - The exact `- name:` value of the step. + * @returns The character offset of that step within the workflow source. + */ +function stepIndex(name: string): number { + const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const match = new RegExp( + `^[ \\t]*-[ \\t]+name:[ \\t]+${escapedName}[ \\t]*(?:#[^\\r\\n]*)?$`, + "m" + ).exec(workflow); + assert.ok(match, `release workflow should contain the exact ${name} step`); + return match.index; +} + +test("npm publication authenticates by OIDC, with no stored token anywhere in the workflow", () => { + // A stored npm token is what silently broke the whole fleet: it was rejected + // from 2026-08-17 onward, every release job failed at the publish step with a + // registry E404 on PUT, and main kept bumping the version regardless. Trusted + // publishing removes the credential that can expire, so this test fails closed + // if a token is ever reintroduced. + const withoutComments = workflow.replace(/^[ \t]*#[^\r\n]*$/gm, ""); + + assert.doesNotMatch(withoutComments, /NODE_AUTH_TOKEN/); + assert.doesNotMatch(withoutComments, /NPM_TOKEN/); + assert.doesNotMatch(withoutComments, /secrets\.NPM/); +}); + + +/** Strip whole-line comments so a commented-out directive can never satisfy an + * assertion that the directive is present. Applied to a slice, never used to + * compute offsets — removing text shifts every index after it. */ +function executable(source: string): string { + return source.replace(/^[ \t]*#[^\r\n]*$/gm, ""); +} + +/** + * Strip comments and the credential-scrub step's own deletion expressions. + * + * The scrub names every credential it removes, so a naive search for those names + * matches the very code that deletes them. Removing `sed` deletion expressions + * leaves only places a credential could actually be *configured*. + * + * @param source - Workflow source to filter. + * @returns Source with comments and scrub deletion expressions removed. + */ +function withoutCredentialScrub(source: string): string { + return executable(source) + // Only the deletion fragments, never a whole line: a line such as + // `sed -i'' -e '/_authToken/d' "$f"; printf '…_authToken=%s' "$S" >> "$f"` + // deletes a credential and then restores one, and dropping the line would + // hide the restore along with the deletion. + .replace(/-e\s+'\/[^']*\/d'/g, "") + .replace(/\bsed\s+-i(?:''|"")?/g, ""); +} + +/** + * Extract the source of one workflow step, from its `- name:` line to the next. + * + * @param name - The exact `- name:` value of the step. + * @returns That step's source alone, excluding neighbouring steps. + */ +function stepSource(name: string): string { + const start = stepIndex(name); + // Derive the boundary from the indentation of the step actually matched. + // A fixed `^ {6}- name:` disagrees with stepIndex, which accepts any + // indentation: if the workflow were reindented, the search would return -1, + // stepSource would return the whole rest of the file, and every scoped + // assertion would silently widen instead of failing. + // stepIndex returns the offset of the line start, so the step's indentation is + // the run of spaces at that offset. + const indent = /^[ \t]*/.exec(workflow.slice(start))?.[0].length ?? 0; + const rest = workflow.slice(start + 1); + const next = rest.search(new RegExp(`^ {${indent}}- name:`, "m")); + return next === -1 ? workflow.slice(start) : workflow.slice(start, start + 1 + next); +} + +/** + * Resolve the permissions block that actually applies to the `release` job. + * + * A job-level `permissions:` block REPLACES the workflow-level one for that job + * rather than merging with it, so reading whichever is nearest is the only + * answer that matches GitHub's semantics. + * + * @returns The effective permissions source for the release job. + */ +/** + * The release job's own source, bounded by the next top-level job key. + * + * Slicing from `jobs:\n release:` to end of file also swallows every LATER + * job, so an assertion meant for the release job was silently being made about + * `alert-on-release-failure` too - a false failure waiting on the next edit to + * that job, reported against the wrong one. + * + * @returns The release job's source with comments removed. + */ +function releaseJobSource(): string { + const jobsAt = workflow.indexOf("jobs:\n release:"); + assert.ok(jobsAt >= 0, "release workflow should declare a jobs.release entry"); + const rest = workflow.slice(jobsAt + "jobs:\n release:".length); + // `[A-Za-z_]`, not `[A-Za-z]`: a job id may begin with an underscore, and a + // slice that does not stop at one runs on into the next job -- letting an + // `id-token: write` declared on `_audit` be read as the release job's own. + const nextJob = rest.search(/^ {2}[A-Za-z_][\w-]*:/m); + return executable(nextJob === -1 ? rest : rest.slice(0, nextJob)); +} + +function effectiveReleasePermissions(): string { + const jobsAt = workflow.indexOf("jobs:\n release:"); + const job = releaseJobSource(); + + // `permissions: { id-token: write }` - a flow mapping is still an override. + // Normalised to one entry per line, because every other branch here returns + // block form and the caller anchors its assertion at end of line. Returned as + // written, `{ id-token: write, contents: write }` put a `}` or a `,` after + // `write`, so this branch could only ever produce a FALSE failure: a + // correctly declared permission reported missing, in the one form the branch + // exists to support. + const inline = /^ {4}permissions:[ \t]*\{([^}]*)\}[ \t]*$/m.exec(job); + if (inline) return inline[1].split(",").map((entry) => entry.trim()).filter(Boolean).join("\n"); + + // `permissions: read-all` and friends are overrides that grant no id-token. + const scalar = /^ {4}permissions:[ \t]*([A-Za-z][\w-]*)[ \t]*$/m.exec(job); + if (scalar) return scalar[1]; + + const jobBlock = /^ {4}permissions:\n((?: {6}\S[^\n]*\n)+)/m.exec(job); + if (jobBlock) return jobBlock[1]; + + const topBlock = /^permissions:\n((?: {2}\S[^\n]*\n)+)/m.exec( + executable(workflow.slice(0, jobsAt)) + ); + assert.ok(topBlock, "release workflow should declare permissions the release job inherits"); + return topBlock[1]; +} + +test("the release job effectively holds id-token: write, and no comment can stand in for it", () => { + // Matching /id-token: write/ against the whole file is satisfied by a comment + // reading "# id-token: write", and by a permission on some other job. Neither + // grants this job anything, and OIDC publication fails closed without it. + assert.match(effectiveReleasePermissions(), /(?:^|[{,\s])id-token:\s*write\s*(?:#[^\n]*)?$/m); +}); + +test("the npm upgrade cannot be skipped and fails closed on the version it actually gets", () => { + // Asserting that the install command appears is not enough: the step can be + // disabled with `if: ${{ false }}` or its failure swallowed with `|| true`, + // and npm 10 stays active while the assertion still passes. The workflow + // checks the EFFECTIVE version and exits non-zero, and that is what is + // asserted here. + const step = executable(stepSource("Use an npm that supports trusted publishing")); + + // Pinned exactly, not a caret range: a privileged publish job that resolves a + // different npm on every run is not reproducible, and an unreviewed 11.x could + // change publishing behaviour between two identical release commits. + assert.match(step, /npm install -g npm@11\.19\.0(?!\S)/); + assert.doesNotMatch(step, /npm install -g npm@[\^~]/); + assert.doesNotMatch(step, /^ *if:/m); + assert.match(step, /npm --version/); + assert.match(step, /sort -V/); + assert.match(step, /exit 1/); + assert.doesNotMatch(step, /\|\|\s*true/); + assert.match(step, /set -euo pipefail/); +}); + +test("nothing between the upgrade and the publish step can put an older npm back", () => { + // Keeping the 11.x upgrade and then installing npm 10 later leaves trusted + // publishing broken while every check above still passes. + const upgrade = stepIndex("Use an npm that supports trusted publishing"); + const publish = stepIndex("Publish npm package"); + assert.ok(upgrade < publish, "npm must be upgraded before the publish step runs"); + + // Through the END of the publish step, not merely up to its start: the + // runtime version gate is the publish step's first command, so an install + // placed after it and before `npm publish` would satisfy every check while + // still publishing with a downgraded npm. + const publishEnd = publish + stepSource("Publish npm package").length; + const between = executable(workflow.slice(upgrade, publishEnd)); + const installs = [ + ...between.matchAll(/npm\s+(?:install|i|add)\s+(?:-g|--global)\s+npm@\S+/g), + ...between.matchAll(/npm\s+(?:-g|--global)\s+(?:install|i|add)\s+npm@\S+/g), + ]; + assert.equal( + installs.length, + 1, + `exactly one global npm install may appear before publication completes, found ${installs.length}` + ); + assert.doesNotMatch(between, /corepack\s+(?:prepare|use)\s+npm@/); + assert.doesNotMatch(between, /uses:\s*actions\/setup-node/); + // Prepending a directory to GITHUB_PATH changes which npm later steps resolve + // without installing anything, so it defeats an install-time check entirely. + // The publish step re-verifies at runtime, but rejecting the write statically + // means the run fails at review time rather than at publication time. + assert.doesNotMatch(between, /GITHUB_PATH/); +}); + +test("no run script in the release job interpolates workflow context", () => { + // Every step here executes with `id-token: write`. Expanding `${{ … }}` into a + // shell script splices attacker-influenceable text - `github.event.repository.name` + // is repository metadata - into a privileged command line. Values reach the + // shell through `env:` instead, where the runner quotes them. + // Only `run:` script bodies. An `env:` entry is exactly where interpolation + // belongs - the runner passes the value as an environment variable rather + // than splicing it into a command line - so flagging those would be noise. + const offenders: string[] = []; + let inRunBlock = false; + for (const line of releaseJobSource().split("\n")) { + if (/^ {8}run: \|/.test(line)) { + inRunBlock = true; + continue; + } + // A `run:` on one line is a script too, just not a block scalar. + if (/^ {8}run: /.test(line)) { + inRunBlock = false; + if (line.includes("${{")) offenders.push(line.trim()); + continue; + } + if (/^ {0,8}\S/.test(line)) inRunBlock = false; + if (inRunBlock && line.includes("${{")) offenders.push(line.trim()); + } + assert.deepEqual(offenders, [], `run scripts must not interpolate workflow context:\n ${offenders.join("\n ")}`); +}); + +test("no registry credential is configured, under any name or mechanism", () => { + // Rejecting three literal token names is not enough: the credential can come + // back as `secrets.PUBLISH_TOKEN` piped into an .npmrc `_authToken` line, or + // through `npm login`, none of which mention NODE_AUTH_TOKEN. What matters is + // that the publish step reaches the registry with no stored credential at all. + const source = withoutCredentialScrub(workflow); + + // npm accepts a registry credential under several names, and rejecting only + // the token spelling leaves the others open: `_auth` is basic auth, + // `username`/`_password` is the legacy pair, and `certfile`/`keyfile` is mTLS. + // Any one of them restores a stored credential the OIDC migration removed. + for (const key of ["_authToken", "_auth", "username", "_password", "certfile", "keyfile"]) { + assert.doesNotMatch( + source, + new RegExp(`${key}\\s*[=:]`), + `release workflow must not configure the npm credential '${key}'` + ); + } + + assert.doesNotMatch(source, /npm\s+(?:config\s+)?set\s+["']?\/\//i); + assert.doesNotMatch(source, /npm\s+login/i); + assert.doesNotMatch(source, /always-auth/i); + // `npm config set //registry.npmjs.org/:_authToken value` separates key and + // value with a space rather than `=`, which the key-plus-`=` checks miss. + assert.doesNotMatch(source, /:(?:_authToken|_auth|username|_password|certfile|keyfile)\s+\S/i); + + // A GLOBAL credential needs no registry scope and no `=`: + // `npm config set _auth ` configures legacy authentication that npm + // honours, while every scoped and delimited check above passes. + // `(?:--\S+\s+)*` matters: `npm config set --global _auth ` and + // `--location=global` both put a flag between `set` and the key, and a + // global credential is written OUTSIDE the userconfig the publish step + // scrubs - so without this the guard passes and the scrub cannot reach it. + assert.doesNotMatch( + source, + /npm\s+(?:config\s+)?set\s+(?:--\S+\s+)*["']?(?:\/\/\S*?[:/])?(?:_authToken|_auth|username|_password|certfile|keyfile|email)\b/i + ); + + // The same credentials can arrive as environment overrides rather than as + // .npmrc lines. NPM_CONFIG_USERCONFIG is the one legitimate member of that + // family here - it is how the publish step finds the file it strips. + for (const [, name] of source.matchAll(/\b(npm_config_[a-z0-9_]+|NPM_CONFIG_[A-Z0-9_]+)\b/gi)) { + assert.equal( + name.toUpperCase(), + "NPM_CONFIG_USERCONFIG", + `release workflow must not set ${name}, which can carry a registry credential` + ); + } + + // The publish step must carry no secret at all. Elsewhere in the job + // `secrets.GITHUB_TOKEN` is legitimate (the gh CLI needs it), so this is + // scoped rather than global. + const publish = executable(stepSource("Publish npm package")); + assert.doesNotMatch(publish, /secrets\s*(?:\.|\[)/); +}); + +test("the empty credential that setup-node generates is removed before publishing", () => { + // `registry-url` makes setup-node write + // `//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}` into the npm + // userconfig. With no token in the environment that expands to an EMPTY + // credential, and npm treats a configured-but-empty token as legacy auth - + // which blocks the OIDC exchange and fails with the very registry 404 this + // migration removes. Deleting the token env is therefore NOT sufficient on + // its own; the generated line has to go too. + // executable(), not raw source: this step's own comments mention _authToken + // and NODE_AUTH_TOKEN, so deleting the scrub while keeping the comment would + // leave every assertion below green against comment text. + const publish = executable(stepSource("Publish npm package")); + + // Checking npm at install time does not bind at publish time: a later step can + // prepend a directory to GITHUB_PATH and change which npm resolves here. + assert.match(publish, /npm --version/); + assert.match(publish, /sort -V/); + assert.ok( + publish.indexOf("npm --version") < publish.indexOf("npm publish"), + "the effective npm must be re-verified before publication, not only at install time" + ); + + assert.match(publish, /NPM_CONFIG_USERCONFIG/); + // A credential written with --global or --location=global lands in npm's + // global config, which the userconfig scrub never touches. + assert.match(publish, /npm config get globalconfig/); + assert.match(publish, /sed -i/); + + // Deleting only the token spelling leaves basic auth, the legacy pair and the + // mTLS pair in place - each of which npm will use instead of the exchange. + // Asserting `publish.includes("_auth")` is satisfied by the `_authToken` + // expression alone, so the loop passed while the basic-auth scrub could be + // deleted outright. Each key is asserted in the DELIMITED form the scrub + // actually uses, which no other key's expression can satisfy. + assert.match(publish, /-e '\/_authToken\/d'/); + for (const key of ["_auth", "username", "_password", "certfile", "keyfile"]) { + assert.match( + publish, + new RegExp(`-e '/:${key}\\[\\[:space:\\]\\]\\*=/d'`), + `the credential scrub must remove the registry-scoped '${key}'` + ); + // npm honours an unscoped credential too, so removing only the scoped form + // leaves legacy authentication configured. + assert.match( + publish, + new RegExp(`-e '/\\^\\[\\[:space:\\]\\]\\*${key}\\[\\[:space:\\]\\]\\*=/d'`), + `the credential scrub must remove the global '${key}'` + ); + } + // The strip must happen before the publish command, not after it. + assert.ok( + publish.indexOf("_authToken") < publish.indexOf("npm publish"), + "the generated credential must be removed before npm publish runs" + ); +}); + +test("publication is proven possible before anything is mutated", () => { + // The failure this guards against is not "publish broke" - it is "publish + // broke and nothing said so". Because the bump and the release commit land + // before the publish step, ten days of rejected credentials still advanced + // main to a new version every night and published nothing. A preflight that + // asks the registry for a credential up front converts that into a run that + // fails immediately, having changed nothing. + const refCheck = stepIndex("Check release ref"); + const preflight = stepIndex("Verify npm will accept this workflow's OIDC identity"); + const bump = stepIndex("Update release version"); + + // The ref check must precede the preflight, not merely precede publication. A + // workflow_dispatch from a feature branch would otherwise mint an id-token and + // exchange it for a short-lived npm PUBLISH CREDENTIAL, and only then be + // refused - obtaining a credential the run is forbidden to use. Refusing on + // the ref is free; requesting a credential is not. + assert.ok( + refCheck < preflight, + "the release ref must be checked before any credential is requested" + ); + + // The step check is not sufficient on its own. `npm ci` runs the checked-out + // package's `prepare` hook, and it runs before any step-level refusal - with + // this job's `id-token: write` held. A workflow_dispatch from a feature ref + // would execute repository-controlled code with release privileges. The job + // itself has to be gated. + const jobHeader = workflow.slice(workflow.indexOf("jobs:\n release:"), stepIndex("Checkout")); + assert.match( + executable(jobHeader), + /^ {4}if: github\.ref == 'refs\/heads\/main'$/m, + "jobs.release must be gated by ref, not only by a step" + ); + assert.match(executable(stepSource("Check release ref")), /refs\/heads\/main/); + const commit = stepIndex("Commit release files"); + const publish = stepIndex("Publish npm package"); + + assert.ok(preflight < bump, "the OIDC check must run before the version is bumped"); + assert.ok(preflight < commit, "the OIDC check must run before the release commit"); + assert.ok(preflight < publish, "the OIDC check must run before publication"); + + const step = executable(stepSource("Verify npm will accept this workflow's OIDC identity")); + + // It has to actually reach the registry: asserting only that an id-token was + // minted would pass while npm still refuses the identity at publish time. + assert.match(step, /oidc\/token\/exchange\/package\//); + + // A scoped name is not path-safe: @unbrained/pm-web must reach the registry as + // %40unbrained%2Fpm-web, and sending it raw addresses a different path. The URL + // must therefore be built from the ENCODED name, not from package.json's value. + // npm's escapedName preserves the leading `@` and encodes only the separator, + // so @unbrained/pm-web must address @unbrained%2fpm-web. encodeURIComponent + // would percent-encode the `@` too and address a path npm does not know. + assert.match(step, /replace\('\/', '%2f'\)/); + assert.doesNotMatch(step, /encodeURIComponent/); + + // An unbounded curl in a release gate turns a hung registry into a hung job + // rather than a failed one, and the job holds an id-token while it hangs. + assert.equal( + (step.match(/curl\b/g) ?? []).length, + (step.match(/--max-time\b/g) ?? []).length, + "every curl in the preflight must be bounded with --max-time" + ); + + // A registry outage is not an identity refusal, and must not send a maintainer + // to reconfigure a trusted publisher that is already correct. + assert.match(step, /-ge 500/); + // Rate limiting says nothing about whether a trusted publisher is bound. + assert.match(step, /"429"/); + + // Under `set -u` a bare ${ACTIONS_ID_TOKEN_*} aborts the step with "unbound + // variable" before the diagnosis can print, so the operator is told nothing. + // The whole purpose of this step is a legible failure. + assert.doesNotMatch(step, /\$\{ACTIONS_ID_TOKEN_REQUEST_(?:URL|TOKEN)\}/); + assert.match(step, /\$\{ACTIONS_ID_TOKEN_REQUEST_URL:-\}/); + assert.match(step, /\$\{ACTIONS_ID_TOKEN_REQUEST_TOKEN:-\}/); + + // Under `set -e` an uncaptured non-zero curl aborts before the 000 branch can + // classify it, making every message below unreachable on exactly the failure + // they exist to describe. Each curl must run inside an `if !` capture. + const curls = (step.match(/curl\b/g) ?? []).length; + const captured = (step.match(/if ! \w+="\$\(curl\b/g) ?? []).length; + assert.equal( + captured, + curls, + `every curl must have its exit status captured for classification (${captured}/${curls})` + ); + assert.match(step, /status="000"/); + assert.match(step, /exchange\/package\/\$\{pkg_path\}/); + assert.doesNotMatch(step, /exchange\/package\/\$\{pkg_name\}/); + + // npm answers 201 on a successful exchange. Accepting only 200 fails a release + // whose trusted publisher is correctly configured - a preflight that blocks + // correct releases is worse than the outage it exists to prevent. + assert.doesNotMatch(step, /\[ "\$\{status\}" = "200" \]/); + assert.match(step, /-ge 200/); + assert.match(step, /-lt 300/); + assert.match(step, /set -euo pipefail/); + assert.match(step, /exit 1/); + assert.doesNotMatch(step, /\|\|\s*true/); + + // Fails closed: no `continue-on-error`, which would restore the silent drift + // while leaving every assertion above satisfied. + assert.doesNotMatch(step, /continue-on-error/); + + // The step legitimately carries an `if:`, so "has no condition" is the wrong + // assertion - but that is exactly the hole `if: ${{ false }}` walks through, + // leaving the step present, ordered correctly, and never executed while the + // bump, commit and publish steps still run. Pin the EXACT condition, and pin + // it to the same one the mutating steps use. + const preflightCondition = /^ *if: steps\.decide\.outputs\.should_release == 'true'$/m; + assert.match(step, preflightCondition); + assert.equal( + (step.match(/^ *if:/gm) ?? []).length, + 1, + "the preflight must carry exactly one condition, so none can shadow the release condition" + ); + assert.match(executable(stepSource("Update release version")), preflightCondition); + + // A second `trap ... EXIT` REPLACES the first, so appending one is enough to + // keep the credential file on disk while every assertion above still passes. + assert.equal( + (step.match(/\btrap\b/g) ?? []).length, + 1, + "exactly one EXIT trap may be installed, or a later one silently replaces the cleanup" + ); + + // The exchange response carries a publish credential. Capturing the status + // separately from the body is what keeps it out of the log. + assert.doesNotMatch(step, /echo\s+"?\$\{?id_token/); + + // The 200 response body IS a short-lived publish credential. Every later step + // in this job runs as the same runner user, so leaving it on disk - and at a + // predictable path - hands that credential to build, changelog and + // release-check code that has no business holding it. + assert.match(step, /response="\$\(mktemp\)"/); + assert.match(step, /trap\s+'rm -f "\$\{response\}"'\s+EXIT/); + assert.doesNotMatch(step, /-o\s+\/tmp\/[^\s"]+/); + assert.match(step, /-o "\$\{response\}"/); +});