diff --git a/.agents/pm/features/pm-github-9dqy.toon b/.agents/pm/features/pm-github-9dqy.toon index 8eb0f3e..090654a 100644 --- a/.agents/pm/features/pm-github-9dqy.toon +++ b/.agents/pm/features/pm-github-9dqy.toon @@ -6,10 +6,13 @@ status: closed priority: 2 tags: [] created_at: "2026-06-03T05:07:15.430Z" -updated_at: "2026-06-03T05:28:25.284Z" +updated_at: "2026-08-28T12:44:03.928Z" +closed_at: "2026-08-28T12:44:03.924Z" +completed_at: "2026-08-28T12:44:03.924Z" author: codex resolution: Round-trip feature delivered + released expected_result: true round-trip + activation fixed actual_result: import/upsert/export/sync/search/validate all working; activation_failed=0 -close_reason: "All 5 tasks delivered + verified vs real public repo (sindresorhus/slugify): fixed activation-breaking manifest gap (preflight+search), added github validate, github search provider, dry-run-default export with provenance upsert, fixed pm-list-vs-list-all upsert dup bug. 16/16 tests pass; release:check green. Capabilities 4/9 -> 6/9 (commands,importers,schema,hooks,preflight,search)." +close_reason: Duplicate of pm-github-4elt +duplicate_of: pm-github-4elt body: "" diff --git a/.agents/pm/history/pm-github-5igz.jsonl b/.agents/pm/history/pm-github-5igz.jsonl new file mode 100644 index 0000000..fc0fb0c --- /dev/null +++ b/.agents/pm/history/pm-github-5igz.jsonl @@ -0,0 +1,6 @@ +{"ts":"2026-08-28T12:38:36.013Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"2017cd99c91bf11aa4148ca1","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-5igz"},{"op":"add","path":"/metadata/title","value":"A publish spelled through a package runner was invisible to the attestation gate, so an unattested one read as clean"},{"op":"add","path":"/metadata/description","value":"The attestation verifier tokenises each shell segment and treats the first non-runner word as the executable. Package runners were absent from that skip list, so in npx npm publish the executable resolved to npx and the segment was not recognised as a publish at all. An unrecognised publish is never checked for the provenance flag, and the failure hides itself: the workflow's ordinary attested publish still satisfies the non-vacuity guard, so the gate reported clean while an unattested publish sat in the same file. This is strictly worse than a missed flag, because nothing in the output suggests anything went unexamined. The same hole existed for bunx, pnpx, and the two-word spellings pnpm dlx, yarn dlx, npm exec and bun x."},{"op":"add","path":"/metadata/type","value":"Issue"},{"op":"add","path":"/metadata/status","value":"open"},{"op":"add","path":"/metadata/priority","value":2},{"op":"add","path":"/metadata/tags","value":[]},{"op":"add","path":"/metadata/created_at","value":"2026-08-28T12:38:36.013Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-08-28T12:38:36.013Z"},{"op":"add","path":"/metadata/author","value":"claude"}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"e67eae4ed3408d1a021ccb601bfa95f153a026ff3f29a25da80bc74d20d69ad3","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-28T12:38:51.885Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"2017cd99c91bf11aa4148ca1","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-28T12:38:51.885Z"},{"op":"add","path":"/metadata/tags/0","value":"area:gates"},{"op":"add","path":"/metadata/tags/1","value":"area:release"},{"op":"add","path":"/metadata/tags/2","value":"area:supply-chain"},{"op":"add","path":"/metadata/tags/3","value":"type:defect"},{"op":"replace","path":"/metadata/priority","value":0},{"op":"add","path":"/metadata/acceptance_criteria","value":"npx, bunx, pnpx and the two-word pnpm dlx, yarn dlx, npm exec and bun x forms are all judged as publishes; a runner-prefixed publish that does carry the attestation flag still passes; the two-word runners are consumed only when the second word matches so a plain npm publish is unaffected; and reverting the skip-list change makes the new tests fail."},{"op":"add","path":"/metadata/risk","value":"critical"},{"op":"add","path":"/metadata/severity","value":"critical"},{"op":"add","path":"/metadata/repro_steps","value":"Call auditPublishAttestation on a file holding an attested plain publish followed by npx npm publish --access public. Before the fix the result carries no failures, because the runner-prefixed publish is not recognised and the attested one satisfies the non-vacuity guard."},{"op":"add","path":"/metadata/expected_result","value":"A publish is judged on what it does, not on how it is spelled, so a runner-prefixed publish is checked for the attestation flag like any other."},{"op":"add","path":"/metadata/actual_result","value":"A runner-prefixed publish was not recognised as a publish, was never checked, and left the gate reporting clean."},{"op":"add","path":"/metadata/component","value":"scripts/verify-release-publish-attestation.ts executableIndex"},{"op":"add","path":"/metadata/customer_impact","value":"A release could publish an unattested artifact while every gate reported green, defeating the supply-chain guarantee the gate exists to provide."}],"before_hash":"e67eae4ed3408d1a021ccb601bfa95f153a026ff3f29a25da80bc74d20d69ad3","after_hash":"6682a9e1167d0663e4bad94b1fd6b55c299667de11eb756c317d3515de62f766","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-28T12:38:54.253Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"2017cd99c91bf11aa4148ca1","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"docs_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-28T12:38:54.253Z"},{"op":"add","path":"/metadata/docs","value":[{"path":"scripts/verify-release-publish-attestation.ts","scope":"project","note":"executableIndex now skips package runners before choosing the executable"}]}],"before_hash":"6682a9e1167d0663e4bad94b1fd6b55c299667de11eb756c317d3515de62f766","after_hash":"dee2bf6b33007af5d1f7c1d9c6c4355c3b6861d8180acd049e05537203de9f84","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-28T12:38:54.925Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"2017cd99c91bf11aa4148ca1","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"docs_add","patch":[{"op":"add","path":"/metadata/docs/1","value":{"path":"test/verify-release-publish-attestation.test.ts","scope":"project","note":"regression cases for every runner spelling plus the attested-runner and two-word-runner mirrors"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-28T12:38:54.925Z"}],"before_hash":"dee2bf6b33007af5d1f7c1d9c6c4355c3b6861d8180acd049e05537203de9f84","after_hash":"498c4cd79a156faa0a17c730c2ee762f80119a18d724b180cb1710599a1fb786","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-28T12:52:01.213Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"2017cd99c91bf11aa4148ca1","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-28T12:52:01.213Z"},{"op":"add","path":"/metadata/comments","value":[{"created_at":"2026-08-28T12:52:01.213Z","author":"claude","text":"A second bypass of the same class, raised by Greptile on PR 57 and confirmed: a shell string handed over through a combined short-option cluster was never inspected. POSIX shells accept bash -ec and bash -euc, which run the string exactly as bash -c does, but the executor resolver matched -c as a whole token only. Measured before the fix: bash -c is caught, while bash -ec, bash -euc and sh -ec all read as clean over an unattested publish, because the workflow's ordinary attested publish still satisfies the non-vacuity guard. The resolver now recognises -c inside a single-dash short-option cluster, and excludes long options deliberately so that --command is not misread as a cluster containing c. Reverting the change fails two of the thirty-two cases."}]}],"before_hash":"498c4cd79a156faa0a17c730c2ee762f80119a18d724b180cb1710599a1fb786","after_hash":"d7e1d06b1ab0d85a7409bfdca076085116ab96559c55f3829dbf78143accb06d","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-28T20:48:39.553Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"gpt-5.6-luna","agent_model_source":"environment","agent_instance":"91352467fd2ac156a36ecf8a","agent_provenance":{"model":{"value":"gpt-5.6-luna","source":"environment"},"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"update","patch":[{"op":"add","path":"/metadata/comments/1","value":{"created_at":"2026-08-28T20:48:39.523Z","author":"pi-agent","text":"Verified pull request 57 before merge. Its required CI checks are green and review threads are resolved. The published gate and its package-runner regression coverage are present on the current head."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-28T20:48:39.553Z"},{"op":"add","path":"/metadata/notes","value":[{"created_at":"2026-08-28T20:48:39.523Z","author":"pi-agent","text":"Local verification passed: npm test, npm run release:check, npm run changelog:full, npm run changelog:check, and pinned pm health --strict-exit. npm run lint is unavailable because package.json has no lint script."}]},{"op":"add","path":"/metadata/tests","value":[{"command":"npm run release:check","scope":"project","timeout_seconds":300,"note":"full release gate for pull request 57"}]}],"before_hash":"d7e1d06b1ab0d85a7409bfdca076085116ab96559c55f3829dbf78143accb06d","after_hash":"f3dccf8455b0a0e2fc7e84bcc666b0001bd16e8fc592088b68b5149030170369","item_hash_version":2,"message":"Verify pull request 57 for merge"} diff --git a/.agents/pm/history/pm-github-9dqy.jsonl b/.agents/pm/history/pm-github-9dqy.jsonl index 2cb4c0a..e769e4f 100644 --- a/.agents/pm/history/pm-github-9dqy.jsonl +++ b/.agents/pm/history/pm-github-9dqy.jsonl @@ -1,2 +1,3 @@ {"ts":"2026-06-03T05:07:15.430Z","author":"codex","op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-github-9dqy"},{"op":"add","path":"/metadata/title","value":"True round-trip GitHub sync: search provider, validate diagnostics, safe-by-default export, fix activation"},{"op":"add","path":"/metadata/description","value":"Deepen pm-github toward true round-trip + fix activation-breaking bug. CRITICAL: published 2026.6.2 manifest omits 'preflight' but code calls registerPreflight -> extension_activate_failed -> whole extension dead. Add validate command, github search provider (maps to local imported items), dry-run-default export upsert."},{"op":"add","path":"/metadata/type","value":"Feature"},{"op":"add","path":"/metadata/status","value":"open"},{"op":"add","path":"/metadata/priority","value":2},{"op":"add","path":"/metadata/tags","value":[]},{"op":"add","path":"/metadata/created_at","value":"2026-06-03T05:07:15.430Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-06-03T05:07:15.430Z"},{"op":"add","path":"/metadata/author","value":"codex"}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"23d20009f5ad6dcbd01700939f47f4f0eef08c461e5f79015793083c14e03146","message":"Plan"} {"ts":"2026-06-03T05:28:25.284Z","author":"codex","op":"close","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-06-03T05:28:25.284Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/resolution","value":"Round-trip feature delivered + released"},{"op":"add","path":"/metadata/expected_result","value":"true round-trip + activation fixed"},{"op":"add","path":"/metadata/actual_result","value":"import/upsert/export/sync/search/validate all working; activation_failed=0"},{"op":"add","path":"/metadata/close_reason","value":"All 5 tasks delivered + verified vs real public repo (sindresorhus/slugify): fixed activation-breaking manifest gap (preflight+search), added github validate, github search provider, dry-run-default export with provenance upsert, fixed pm-list-vs-list-all upsert dup bug. 16/16 tests pass; release:check green. Capabilities 4/9 -> 6/9 (commands,importers,schema,hooks,preflight,search)."}],"before_hash":"23d20009f5ad6dcbd01700939f47f4f0eef08c461e5f79015793083c14e03146","after_hash":"229bafb0ccb9aed44d48053e9d8a77ca7b7cbb90bf3ff7f2da8fb19da8f6688a"} +{"ts":"2026-08-28T12:44:03.928Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"2017cd99c91bf11aa4148ca1","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"close","patch":[{"op":"replace","path":"/metadata/close_reason","value":"Duplicate of pm-github-4elt"},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-28T12:44:03.928Z"},{"op":"add","path":"/metadata/closed_at","value":"2026-08-28T12:44:03.924Z"},{"op":"add","path":"/metadata/completed_at","value":"2026-08-28T12:44:03.924Z"},{"op":"add","path":"/metadata/duplicate_of","value":"pm-github-4elt"}],"before_hash":"229bafb0ccb9aed44d48053e9d8a77ca7b7cbb90bf3ff7f2da8fb19da8f6688a","after_hash":"def107aded726509fbc039078ea0c08993c8c5fddd71be3eca5ef6c647d19259","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-9m6j.jsonl b/.agents/pm/history/pm-github-9m6j.jsonl index 4ef196e..7809b92 100644 --- a/.agents/pm/history/pm-github-9m6j.jsonl +++ b/.agents/pm/history/pm-github-9m6j.jsonl @@ -1,10 +1,11 @@ -{"ts":"2026-08-03T07:13:29.742Z","author":"harness:claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"1e72a537038c81a027f7e6d2","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"1","source":"environment"},"topic":null},"op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-github-9m6j"},{"op":"add","path":"/metadata/title","value":"Resolve pm-changelog to the release that derives release dates in UTC"},{"op":"add","path":"/metadata/description","value":"The lockfile pinned a pm-changelog older than 2026.8.2, which derived the release-heading date in local time. A host at a positive UTC offset generating a changelog late in the evening produced tomorrow's heading while a UTC runner regenerating the same tracker produced today's, so the committed file and changelog:check disagreed for reasons unrelated to the tracker. The declared dependency range already admitted the fixed release, so no dependency update was ever proposed and only the lockfile still held the old version. The exposure was latent rather than active because the daily release job generates and checks inside a single UTC instant on GitHub and therefore agrees with itself; it bites an agent or a developer regenerating locally, which has happened twice in this fleet."},{"op":"add","path":"/metadata/type","value":"Chore"},{"op":"add","path":"/metadata/status","value":"open"},{"op":"add","path":"/metadata/priority","value":2},{"op":"add","path":"/metadata/tags","value":["area:release","dependencies","pm-changelog"]},{"op":"add","path":"/metadata/created_at","value":"2026-08-03T07:13:29.742Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-08-03T07:13:29.742Z"},{"op":"add","path":"/metadata/author","value":"harness:claude-code"},{"op":"add","path":"/metadata/acceptance_criteria","value":"The lockfile resolves pm-changelog to 2026.8.2 or newer; generating this package's changelog under TZ=UTC and under a timezone a day behind at one instant produces byte-identical output; changelog:check passes against the committed CHANGELOG.md"}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"0c02c0c84a25dd4922e028dd1df94c4c5c7dd2a53d5000d5e2969e15054559bf","message":""} -{"ts":"2026-08-03T07:32:58.729Z","author":"harness:claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"1e72a537038c81a027f7e6d2","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"1","source":"environment"},"topic":null},"op":"close","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-03T07:32:58.729Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-08-03T07:32:58.725Z"},{"op":"add","path":"/metadata/completed_at","value":"2026-08-03T07:32:58.725Z"},{"op":"add","path":"/metadata/resolution","value":"Refreshed the lockfile so pm-changelog resolves to 2026.8.3, which derives the release-heading date in UTC."},{"op":"add","path":"/metadata/expected_result","value":"Changelog generation is independent of the generating host's timezone, so the committed file and changelog:check agree regardless of where either runs."},{"op":"add","path":"/metadata/actual_result","value":"Generating under TZ=UTC and TZ=Etc/GMT+12 at one instant produces byte-identical output; before the bump the same comparison produced headings one day apart. changelog:check passes against the committed CHANGELOG.md."},{"op":"add","path":"/metadata/close_reason","value":"Lockfile now resolves pm-changelog 2026.8.3, which derives the release-heading date in UTC. Verified against the acceptance criteria: generating this package's changelog under TZ=UTC and TZ=Etc/GMT+12 at one instant produces byte-identical output, and changelog:check passes against the committed CHANGELOG.md."}],"before_hash":"0c02c0c84a25dd4922e028dd1df94c4c5c7dd2a53d5000d5e2969e15054559bf","after_hash":"5c52f4f242478b53d502d45ad11db61672b8f3dc5d2266c17ce6bdbbd3fa5502"} -{"ts":"2026-08-03T07:56:04.431Z","author":"harness:claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"1e72a537038c81a027f7e6d2","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"1","source":"environment"},"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/acceptance_criteria","value":"The lockfile resolves pm-changelog to 2026.8.2 or newer; generating this package's changelog twice at one instant with the same fixed release version, once under TZ=UTC and once under TZ=Etc/GMT+12 (a fixed minus-twelve offset, which is on the previous calendar day whenever the UTC time of day is before 12:00), produces byte-identical output; changelog:check passes against the committed CHANGELOG.md"},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-03T07:56:04.431Z"}],"before_hash":"5c52f4f242478b53d502d45ad11db61672b8f3dc5d2266c17ce6bdbbd3fa5502","after_hash":"3db542fb924bf7965a8e3ac468118c0533c9ee7a35ab2c851f0661004ad8aaac"} -{"ts":"2026-08-03T07:56:04.765Z","author":"harness:claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"1e72a537038c81a027f7e6d2","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"1","source":"environment"},"topic":null},"op":"note_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-03T07:56:04.765Z"},{"op":"add","path":"/metadata/notes","value":[{"created_at":"2026-08-03T07:56:04.765Z","author":"harness:claude-code","text":"Verification record. Resolved pm-changelog version in the lockfile: 2026.8.3. Both generations used --release-version 2099.1.1, a version with no tag, which is what forces the today-fallback path where the defect lives; a tagged version takes its date from the tag and cannot show the drift. Run at a UTC time of day before 12:00, so TZ=Etc/GMT+12 was on the previous calendar day and the two runs straddled a date boundary. Both emitted the same heading. Against the previous pinned version the same comparison produced headings one day apart, which is the defect. changelog:check passes against the committed CHANGELOG.md."}]}],"before_hash":"3db542fb924bf7965a8e3ac468118c0533c9ee7a35ab2c851f0661004ad8aaac","after_hash":"0491c804d6d5b413f65b3f71da8d3b552e5ff8c0e81ee40e5f67c45f003e97b1"} -{"ts":"2026-08-03T08:09:45.872Z","author":"harness:claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"1e72a537038c81a027f7e6d2","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"1","source":"environment"},"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/acceptance_criteria","value":"The lockfile resolves pm-changelog to 2026.8.2 or newer; generating this package's changelog at one instant with the same untagged release version under all three of TZ=UTC, TZ=Etc/GMT+12 and TZ=Etc/GMT-14 produces byte-identical output; changelog:check passes against the committed CHANGELOG.md. The three zones are required together so the check crosses a calendar-date boundary at any hour: the minus-twelve zone is on the previous UTC date only while the UTC time of day is before 12:00, and the plus-fourteen zone is on the next one only from 10:00, so their union covers the whole day and no run can pass by being made at a quiet hour. The release version must be untagged because --date resolves from the tag whenever one exists, so a tagged version takes its heading from the tag and cannot exhibit this defect at all."},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-03T08:09:45.872Z"}],"before_hash":"0491c804d6d5b413f65b3f71da8d3b552e5ff8c0e81ee40e5f67c45f003e97b1","after_hash":"7a77347f50132d4d9db498793c9b6b438830d5c49dade27967ee8954b6ac8771"} -{"ts":"2026-08-03T08:20:59.387Z","author":"harness:claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"1e72a537038c81a027f7e6d2","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"1","source":"environment"},"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/actual_result","value":"All three zones agree. Re-ran at 2026-08-03T08:2x UTC under TZ=UTC, TZ=Etc/GMT+12 and TZ=Etc/GMT-14 with the untagged release version 2099.1.1; the three headings were byte-identical (distinct heading values observed: 1). At that instant the minus-twelve zone was on the previous calendar date, so the comparison genuinely crossed a date boundary rather than comparing two zones that happened to share a date. Against the previous pinned pm-changelog the same comparison produced headings one day apart. changelog:check passes against the committed CHANGELOG.md."},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-03T08:20:59.387Z"}],"before_hash":"7a77347f50132d4d9db498793c9b6b438830d5c49dade27967ee8954b6ac8771","after_hash":"decfb36a0417dde2d5c47fb66ca32d21a1da2a3b708521e46a15e68d6a2c0e2b"} -{"ts":"2026-08-03T08:20:59.715Z","author":"harness:claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"1e72a537038c81a027f7e6d2","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"1","source":"environment"},"topic":null},"op":"note_add","patch":[{"op":"add","path":"/metadata/notes/1","value":{"created_at":"2026-08-03T08:20:59.715Z","author":"harness:claude-code","text":"Three-zone verification evidence, recorded after review pointed out the criteria required a GMT-14 run that the completion record did not document. Observed headings: TZ=UTC:[## 2099.1.1 - 2026-08-03] TZ=Etc/GMT+12:[## 2099.1.1 - 2026-08-03] TZ=Etc/GMT-14:[## 2099.1.1 - 2026-08-03]. Distinct heading values across the three zones: 1 (1 means they agree)."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-03T08:20:59.715Z"}],"before_hash":"decfb36a0417dde2d5c47fb66ca32d21a1da2a3b708521e46a15e68d6a2c0e2b","after_hash":"04bd353d87bafc7c96bb87912b1c41c5aababdc19938ce0e17b9aab576c24d54"} -{"ts":"2026-08-03T08:35:38.398Z","author":"harness:claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"1e72a537038c81a027f7e6d2","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"1","source":"environment"},"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/actual_result","value":"Byte-identical across all three zones, verified at 2026-08-03T08:34:10Z. The whole generated document was hashed, not just the heading line: sha256 prefix fc05a07a3be85a83 under each of TZ=UTC, TZ=Etc/GMT+12 and TZ=Etc/GMT-14, using the untagged release version 2099.1.1 which is what forces the today-fallback path where the defect lives. changelog:check passes against the committed CHANGELOG.md."},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-03T08:35:38.398Z"}],"before_hash":"04bd353d87bafc7c96bb87912b1c41c5aababdc19938ce0e17b9aab576c24d54","after_hash":"f2d1643ce8145f122b2a6e7c71cfe76ff07b819cf9eccef76ca14c460a52f4c8"} -{"ts":"2026-08-03T08:35:38.779Z","author":"harness:claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"1e72a537038c81a027f7e6d2","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"1","source":"environment"},"topic":null},"op":"note_add","patch":[{"op":"add","path":"/metadata/notes/2","value":{"created_at":"2026-08-03T08:35:38.779Z","author":"harness:claude-code","text":"Correction to the previous verification note, which claimed a run time of 08:2x - not a real instant - and compared only the release heading line while the acceptance criterion requires byte-identical output. Byte-identical across all three zones, verified at 2026-08-03T08:34:10Z. The whole generated document was hashed, not just the heading line: sha256 prefix fc05a07a3be85a83 under each of TZ=UTC, TZ=Etc/GMT+12 and TZ=Etc/GMT-14, using the untagged release version 2099.1.1 which is what forces the today-fallback path where the defect lives. changelog:check passes against the committed CHANGELOG.md."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-03T08:35:38.779Z"}],"before_hash":"f2d1643ce8145f122b2a6e7c71cfe76ff07b819cf9eccef76ca14c460a52f4c8","after_hash":"18c2e1c8ae08de16ff0d5dad86ae91b435999f4358c6a658eb15034345c024bf"} -{"ts":"2026-08-03T08:36:21.505Z","author":"harness:claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"1e72a537038c81a027f7e6d2","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"1","source":"environment"},"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/close_reason","value":"Lockfile resolves pm-changelog 2026.8.3, which derives the release-heading date in UTC. Verified against the final acceptance criteria at 2026-08-03T08:34:10Z: the whole generated document, not only its heading line, hashes to sha256 prefix fc05a07a3be85a83 under each of TZ=UTC, TZ=Etc/GMT+12 and TZ=Etc/GMT-14, generated with the untagged release version 2099.1.1 because a tagged version takes its date from the tag and cannot exhibit this defect. changelog:check passes against the committed CHANGELOG.md."},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-03T08:36:21.505Z"}],"before_hash":"18c2e1c8ae08de16ff0d5dad86ae91b435999f4358c6a658eb15034345c024bf","after_hash":"53d8d2bc89226796cd1c4ca00d4de08d5bb9fd09a218170e5b568d3e2cd4c329"} +{"ts":"2026-08-03T07:13:29.742Z","author":"harness:claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"1e72a537038c81a027f7e6d2","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"topic":null},"op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-github-9m6j"},{"op":"add","path":"/metadata/title","value":"Resolve pm-changelog to the release that derives release dates in UTC"},{"op":"add","path":"/metadata/description","value":"The lockfile pinned a pm-changelog older than 2026.8.2, which derived the release-heading date in local time. A host at a positive UTC offset generating a changelog late in the evening produced tomorrow's heading while a UTC runner regenerating the same tracker produced today's, so the committed file and changelog:check disagreed for reasons unrelated to the tracker. The declared dependency range already admitted the fixed release, so no dependency update was ever proposed and only the lockfile still held the old version. The exposure was latent rather than active because the daily release job generates and checks inside a single UTC instant on GitHub and therefore agrees with itself; it bites an agent or a developer regenerating locally, which has happened twice in this fleet."},{"op":"add","path":"/metadata/type","value":"Chore"},{"op":"add","path":"/metadata/status","value":"open"},{"op":"add","path":"/metadata/priority","value":2},{"op":"add","path":"/metadata/tags","value":["area:release","dependencies","pm-changelog"]},{"op":"add","path":"/metadata/created_at","value":"2026-08-03T07:13:29.742Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-08-03T07:13:29.742Z"},{"op":"add","path":"/metadata/author","value":"harness:claude-code"},{"op":"add","path":"/metadata/acceptance_criteria","value":"The lockfile resolves pm-changelog to 2026.8.2 or newer; generating this package's changelog under TZ=UTC and under a timezone a day behind at one instant produces byte-identical output; changelog:check passes against the committed CHANGELOG.md"}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"0c02c0c84a25dd4922e028dd1df94c4c5c7dd2a53d5000d5e2969e15054559bf","message":""} +{"ts":"2026-08-03T07:32:58.729Z","author":"harness:claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"1e72a537038c81a027f7e6d2","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"topic":null},"op":"close","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-03T07:32:58.729Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-08-03T07:32:58.725Z"},{"op":"add","path":"/metadata/completed_at","value":"2026-08-03T07:32:58.725Z"},{"op":"add","path":"/metadata/resolution","value":"Refreshed the lockfile so pm-changelog resolves to 2026.8.3, which derives the release-heading date in UTC."},{"op":"add","path":"/metadata/expected_result","value":"Changelog generation is independent of the generating host's timezone, so the committed file and changelog:check agree regardless of where either runs."},{"op":"add","path":"/metadata/actual_result","value":"Generating under TZ=UTC and TZ=Etc/GMT+12 at one instant produces byte-identical output; before the bump the same comparison produced headings one day apart. changelog:check passes against the committed CHANGELOG.md."},{"op":"add","path":"/metadata/close_reason","value":"Lockfile now resolves pm-changelog 2026.8.3, which derives the release-heading date in UTC. Verified against the acceptance criteria: generating this package's changelog under TZ=UTC and TZ=Etc/GMT+12 at one instant produces byte-identical output, and changelog:check passes against the committed CHANGELOG.md."}],"before_hash":"0c02c0c84a25dd4922e028dd1df94c4c5c7dd2a53d5000d5e2969e15054559bf","after_hash":"5c52f4f242478b53d502d45ad11db61672b8f3dc5d2266c17ce6bdbbd3fa5502"} +{"ts":"2026-08-03T07:56:04.431Z","author":"harness:claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"1e72a537038c81a027f7e6d2","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/acceptance_criteria","value":"The lockfile resolves pm-changelog to 2026.8.2 or newer; generating this package's changelog twice at one instant with the same fixed release version, once under TZ=UTC and once under TZ=Etc/GMT+12 (a fixed minus-twelve offset, which is on the previous calendar day whenever the UTC time of day is before 12:00), produces byte-identical output; changelog:check passes against the committed CHANGELOG.md"},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-03T07:56:04.431Z"}],"before_hash":"5c52f4f242478b53d502d45ad11db61672b8f3dc5d2266c17ce6bdbbd3fa5502","after_hash":"3db542fb924bf7965a8e3ac468118c0533c9ee7a35ab2c851f0661004ad8aaac"} +{"ts":"2026-08-03T07:56:04.765Z","author":"harness:claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"1e72a537038c81a027f7e6d2","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"topic":null},"op":"note_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-03T07:56:04.765Z"},{"op":"add","path":"/metadata/notes","value":[{"created_at":"2026-08-03T07:56:04.765Z","author":"harness:claude-code","text":"Verification record. Resolved pm-changelog version in the lockfile: 2026.8.3. Both generations used --release-version 2099.1.1, a version with no tag, which is what forces the today-fallback path where the defect lives; a tagged version takes its date from the tag and cannot show the drift. Run at a UTC time of day before 12:00, so TZ=Etc/GMT+12 was on the previous calendar day and the two runs straddled a date boundary. Both emitted the same heading. Against the previous pinned version the same comparison produced headings one day apart, which is the defect. changelog:check passes against the committed CHANGELOG.md."}]}],"before_hash":"3db542fb924bf7965a8e3ac468118c0533c9ee7a35ab2c851f0661004ad8aaac","after_hash":"0491c804d6d5b413f65b3f71da8d3b552e5ff8c0e81ee40e5f67c45f003e97b1"} +{"ts":"2026-08-03T08:09:45.872Z","author":"harness:claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"1e72a537038c81a027f7e6d2","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/acceptance_criteria","value":"The lockfile resolves pm-changelog to 2026.8.2 or newer; generating this package's changelog at one instant with the same untagged release version under all three of TZ=UTC, TZ=Etc/GMT+12 and TZ=Etc/GMT-14 produces byte-identical output; changelog:check passes against the committed CHANGELOG.md. The three zones are required together so the check crosses a calendar-date boundary at any hour: the minus-twelve zone is on the previous UTC date only while the UTC time of day is before 12:00, and the plus-fourteen zone is on the next one only from 10:00, so their union covers the whole day and no run can pass by being made at a quiet hour. The release version must be untagged because --date resolves from the tag whenever one exists, so a tagged version takes its heading from the tag and cannot exhibit this defect at all."},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-03T08:09:45.872Z"}],"before_hash":"0491c804d6d5b413f65b3f71da8d3b552e5ff8c0e81ee40e5f67c45f003e97b1","after_hash":"7a77347f50132d4d9db498793c9b6b438830d5c49dade27967ee8954b6ac8771"} +{"ts":"2026-08-03T08:20:59.387Z","author":"harness:claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"1e72a537038c81a027f7e6d2","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/actual_result","value":"All three zones agree. Re-ran at 2026-08-03T08:2x UTC under TZ=UTC, TZ=Etc/GMT+12 and TZ=Etc/GMT-14 with the untagged release version 2099.1.1; the three headings were byte-identical (distinct heading values observed: 1). At that instant the minus-twelve zone was on the previous calendar date, so the comparison genuinely crossed a date boundary rather than comparing two zones that happened to share a date. Against the previous pinned pm-changelog the same comparison produced headings one day apart. changelog:check passes against the committed CHANGELOG.md."},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-03T08:20:59.387Z"}],"before_hash":"7a77347f50132d4d9db498793c9b6b438830d5c49dade27967ee8954b6ac8771","after_hash":"decfb36a0417dde2d5c47fb66ca32d21a1da2a3b708521e46a15e68d6a2c0e2b"} +{"ts":"2026-08-03T08:20:59.715Z","author":"harness:claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"1e72a537038c81a027f7e6d2","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"topic":null},"op":"note_add","patch":[{"op":"add","path":"/metadata/notes/1","value":{"created_at":"2026-08-03T08:20:59.715Z","author":"harness:claude-code","text":"Three-zone verification evidence, recorded after review pointed out the criteria required a GMT-14 run that the completion record did not document. Observed headings: TZ=UTC:[## 2099.1.1 - 2026-08-03] TZ=Etc/GMT+12:[## 2099.1.1 - 2026-08-03] TZ=Etc/GMT-14:[## 2099.1.1 - 2026-08-03]. Distinct heading values across the three zones: 1 (1 means they agree)."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-03T08:20:59.715Z"}],"before_hash":"decfb36a0417dde2d5c47fb66ca32d21a1da2a3b708521e46a15e68d6a2c0e2b","after_hash":"04bd353d87bafc7c96bb87912b1c41c5aababdc19938ce0e17b9aab576c24d54"} +{"ts":"2026-08-03T08:35:38.398Z","author":"harness:claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"1e72a537038c81a027f7e6d2","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/actual_result","value":"Byte-identical across all three zones, verified at 2026-08-03T08:34:10Z. The whole generated document was hashed, not just the heading line: sha256 prefix fc05a07a3be85a83 under each of TZ=UTC, TZ=Etc/GMT+12 and TZ=Etc/GMT-14, using the untagged release version 2099.1.1 which is what forces the today-fallback path where the defect lives. changelog:check passes against the committed CHANGELOG.md."},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-03T08:35:38.398Z"}],"before_hash":"04bd353d87bafc7c96bb87912b1c41c5aababdc19938ce0e17b9aab576c24d54","after_hash":"f2d1643ce8145f122b2a6e7c71cfe76ff07b819cf9eccef76ca14c460a52f4c8"} +{"ts":"2026-08-03T08:35:38.779Z","author":"harness:claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"1e72a537038c81a027f7e6d2","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"topic":null},"op":"note_add","patch":[{"op":"add","path":"/metadata/notes/2","value":{"created_at":"2026-08-03T08:35:38.779Z","author":"harness:claude-code","text":"Correction to the previous verification note, which claimed a run time of 08:2x - not a real instant - and compared only the release heading line while the acceptance criterion requires byte-identical output. Byte-identical across all three zones, verified at 2026-08-03T08:34:10Z. The whole generated document was hashed, not just the heading line: sha256 prefix fc05a07a3be85a83 under each of TZ=UTC, TZ=Etc/GMT+12 and TZ=Etc/GMT-14, using the untagged release version 2099.1.1 which is what forces the today-fallback path where the defect lives. changelog:check passes against the committed CHANGELOG.md."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-03T08:35:38.779Z"}],"before_hash":"f2d1643ce8145f122b2a6e7c71cfe76ff07b819cf9eccef76ca14c460a52f4c8","after_hash":"18c2e1c8ae08de16ff0d5dad86ae91b435999f4358c6a658eb15034345c024bf"} +{"ts":"2026-08-03T08:36:21.505Z","author":"harness:claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"1e72a537038c81a027f7e6d2","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/close_reason","value":"Lockfile resolves pm-changelog 2026.8.3, which derives the release-heading date in UTC. Verified against the final acceptance criteria at 2026-08-03T08:34:10Z: the whole generated document, not only its heading line, hashes to sha256 prefix fc05a07a3be85a83 under each of TZ=UTC, TZ=Etc/GMT+12 and TZ=Etc/GMT-14, generated with the untagged release version 2099.1.1 because a tagged version takes its date from the tag and cannot exhibit this defect. changelog:check passes against the committed CHANGELOG.md."},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-03T08:36:21.505Z"}],"before_hash":"18c2e1c8ae08de16ff0d5dad86ae91b435999f4358c6a658eb15034345c024bf","after_hash":"53d8d2bc89226796cd1c4ca00d4de08d5bb9fd09a218170e5b568d3e2cd4c329"} +{"ts":"2026-08-28T13:21:47.145Z","author":"claude","op":"history_repair","patch":[],"before_hash":"53d8d2bc89226796cd1c4ca00d4de08d5bb9fd09a218170e5b568d3e2cd4c329","after_hash":"53d8d2bc89226796cd1c4ca00d4de08d5bb9fd09a218170e5b568d3e2cd4c329","message":"history-repair re-anchored 0 entries.","context":{"provenance_normalization":{"changed":true,"events_changed":10,"observations_removed":10,"invalid_values":[{"harness":"claude-code","dimension":"role","kind":"single_digit","count":10}]}}} diff --git a/.agents/pm/history/pm-github-eh1h.jsonl b/.agents/pm/history/pm-github-eh1h.jsonl index bbb20e5..6b08368 100644 --- a/.agents/pm/history/pm-github-eh1h.jsonl +++ b/.agents/pm/history/pm-github-eh1h.jsonl @@ -1,8 +1,8 @@ {"ts":"2026-08-06T20:44:51.948Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.2:cloud","agent_model_source":"environment","agent_instance":"549ee0e9d5103407c0b3bd52","agent_provenance":{"model":{"value":"glm-5.2:cloud","source":"environment"},"effort":null,"role":null,"topic":null},"op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-github-eh1h"},{"op":"add","path":"/metadata/title","value":"Gate CI on pm health so a silently discarded peer edit cannot merge"},{"op":"add","path":"/metadata/description","value":""},{"op":"add","path":"/metadata/type","value":"Chore"},{"op":"add","path":"/metadata/status","value":"open"},{"op":"add","path":"/metadata/priority","value":2},{"op":"add","path":"/metadata/tags","value":[]},{"op":"add","path":"/metadata/created_at","value":"2026-08-06T20:44:51.948Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-08-06T20:44:51.948Z"},{"op":"add","path":"/metadata/author","value":"pi-agent"},{"op":"add","path":"/metadata/acceptance_criteria","value":"CI workflow runs ./node_modules/.bin/pm health --strict-exit in a step immediately after Install dependencies; the step fails non-zero when integrity.counts.pending_merge_decisions is non-zero; pm validate is NOT used because it returns ok:true on a marker-free field-aware merge that silently dropped a peer edit"}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"d755c81f334bcdf4c44d5d6294f6fce46e1ae2352050e5ac74ac56b9c182edbc","message":""} {"ts":"2026-08-06T20:44:52.365Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.2:cloud","agent_model_source":"environment","agent_instance":"549ee0e9d5103407c0b3bd52","agent_provenance":{"model":{"value":"glm-5.2:cloud","source":"environment"},"effort":null,"role":null,"topic":null},"op":"close","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-06T20:44:52.365Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-08-06T20:44:52.355Z"},{"op":"add","path":"/metadata/completed_at","value":"2026-08-06T20:44:52.355Z"},{"op":"add","path":"/metadata/close_reason","value":"Inserted the Verify pm project integrity and merge safety step into .github/workflows/ci.yml right after Install dependencies, running ./node_modules/.bin/pm health --strict-exit. pm health --strict-exit exits 0 on this repo and was proven to exit 1 on a same-field merge hazard in a throwaway copy where pm validate stayed ok:true."}],"before_hash":"d755c81f334bcdf4c44d5d6294f6fce46e1ae2352050e5ac74ac56b9c182edbc","after_hash":"316537b59febb3cc3d3658d692dfc821f6cf8843f383642cb342f447f54870d9"} {"ts":"2026-08-06T21:21:14.430Z","author":"codex","author_source":"asserted","agent_harness":"codex","agent_instance":"f1530d70463641787965c6dc","agent_provenance":{"model":null,"effort":null,"role":null,"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/close_reason","value":"Added the CI tracked-state health gate, corrected workflow and changelog language after Greptile identified the fresh-clone receipt boundary, retained local receipt reconciliation as a pre-push requirement, and filed pm-cli#921 with 2026.8.6 black-box evidence."},{"op":"replace","path":"/metadata/acceptance_criteria","value":"CI runs pm health --strict-exit immediately after dependency installation; clean durable repository state exits 0; tracked history, attribution, cache, extension, or available merge-evidence failures block CI; documentation does not claim fresh CI can inspect clone-local receipts"},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-06T21:21:14.430Z"},{"op":"replace","path":"/metadata/description","value":"Run pm health --strict-exit in CI to reject durable tracker-health failures. The fresh runner cannot inspect clone-local merge receipts; pm-cli#921 tracks the remaining attestation gap, so receipt reconciliation stays a local pre-push responsibility."},{"op":"replace","path":"/metadata/title","value":"Gate CI on strict tracked pm project health"},{"op":"add","path":"/metadata/resolution","value":"Added a strict tracked-state health gate and corrected the merge-safety boundary after exact reviewer feedback."},{"op":"add","path":"/metadata/expected_result","value":"CI rejects durable pm project-health failures without overstating coverage of clone-local merge receipts."},{"op":"add","path":"/metadata/actual_result","value":"Package health passes locally. Published pm 2026.8.6 black-box proof showed pending receipts disappear in a fresh clone, durable history drift blocks the unrepaired merge, and history-repair can erase that last CI signal; upstream pm-cli#921 records the gap."},{"op":"add","path":"/metadata/notes","value":[{"created_at":"2026-08-06T21:21:14.406Z","author":"codex","text":"Greptile P1 was valid as a scope correction: a fresh checkout cannot inspect .git-local receipts. The gate remains useful for durable health and catches unrepaired lossy merges through history drift,but it is not a complete no-loss guarantee."}]},{"op":"add","path":"/metadata/tests","value":[{"command":"pm health --strict-exit","scope":"project","timeout_seconds":120}]},{"op":"add","path":"/metadata/docs","value":[{"path":"https://github.com/unbraind/pm-cli/issues/921","scope":"global","note":"upstream durable merge-attestation gap"}]}],"before_hash":"316537b59febb3cc3d3658d692dfc821f6cf8843f383642cb342f447f54870d9","after_hash":"3e6b669bdcb0212a18649daff0cc7b83812e45039ba9357e58964bf2147d4ad2","message":"Correct the CI gate contract after fresh-clone black-box proof"} -{"ts":"2026-08-06T21:27:18.017Z","author":"harness:claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"db7a36e250bba74236c8424b","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"1","source":"environment"},"topic":null},"op":"note_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-06T21:27:18.017Z"},{"op":"add","path":"/metadata/notes","value":[{"created_at":"2026-08-06T21:27:18.017Z","author":"harness:claude-code","text":"CORRECTION 2026-08-06, verified directly rather than reasoned about. This item's original close reason overstated what the gate does, and the CI comment has been rewritten to match reality.\n\nWHAT WAS WRONG. The gate was described as blocking a silently discarded peer edit. It does not, and cannot as currently built. Merge-decision receipts are written to .agents/pm/runtime/, which pm init itself gitignores, so they are never pushed. Measured on the same commit: the clone that performed the merge reports 1 receipt and the strict health check exits 1, while a fresh clone of that exact commit reports 0 receipts and the strict health check exits 0. CI checks out the fresh state, so the discarded edit passes.\n\nTHE PUSHED HISTORY DOES NOT DISAMBIGUATE IT EITHER. The merged history stream contains both writes in timestamp order and the item matches the latest one, so history verification reports current_matches_latest true with matching hashes. From pushed state alone, two concurrent branch edits where one was discarded look exactly like two sequential edits where the second superseded the first.\n\nRECONCILE PUBLISHES THE EVIDENCE BUT DOES NOT MAKE IT GATEABLE. Running merge reconcile appends a merge_reconcile event into tracked history carrying conflict_fields, retained_hash and discarded_hash, so durable evidence can exist. But the strict health check then exits 0 in both clones, and reconcile is optional anyway. There is no configuration in which CI observes the loss.\n\nCREDIT. Greptile flagged this as P1 on every gate PR in the rollout before it merged. Verified independently and filed upstream as pm-cli 922.\n\nWHAT THE GATE ACTUALLY DOES, all observable in a fresh checkout and all worth gating on: conflict markers left in item or history files, item parse failures, invalid history JSON, history hash drift, unknown-author history events, stale in-progress work, and tracked runtime cache files. The strict-exit flag remains load-bearing because a bare health check exits 0 even when not ok.\n\nThe step is kept for that real value. Only the claim was wrong, and a gate whose documentation overstates its guarantee is worse than no gate because it stops people looking for the real hazard."}]}],"before_hash":"3e6b669bdcb0212a18649daff0cc7b83812e45039ba9357e58964bf2147d4ad2","after_hash":"5c84d74a6ac8aba5480cad952f34a3e94caaef188526c5385c110a3762e6b45f"} -{"ts":"2026-08-06T21:27:18.566Z","author":"harness:claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"db7a36e250bba74236c8424b","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"1","source":"environment"},"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-06T21:27:18.566Z"},{"op":"replace","path":"/metadata/title","value":"Gate CI on pm health for tracker integrity (does not cover discarded peer edits, see pm-cli 922)"}],"before_hash":"5c84d74a6ac8aba5480cad952f34a3e94caaef188526c5385c110a3762e6b45f","after_hash":"0aa20e7fc9faec27df3779ac98a354310d076641bc469f629f17b69b2e322afd"} +{"ts":"2026-08-06T21:27:18.017Z","author":"harness:claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"db7a36e250bba74236c8424b","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"topic":null},"op":"note_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-06T21:27:18.017Z"},{"op":"add","path":"/metadata/notes","value":[{"created_at":"2026-08-06T21:27:18.017Z","author":"harness:claude-code","text":"CORRECTION 2026-08-06, verified directly rather than reasoned about. This item's original close reason overstated what the gate does, and the CI comment has been rewritten to match reality.\n\nWHAT WAS WRONG. The gate was described as blocking a silently discarded peer edit. It does not, and cannot as currently built. Merge-decision receipts are written to .agents/pm/runtime/, which pm init itself gitignores, so they are never pushed. Measured on the same commit: the clone that performed the merge reports 1 receipt and the strict health check exits 1, while a fresh clone of that exact commit reports 0 receipts and the strict health check exits 0. CI checks out the fresh state, so the discarded edit passes.\n\nTHE PUSHED HISTORY DOES NOT DISAMBIGUATE IT EITHER. The merged history stream contains both writes in timestamp order and the item matches the latest one, so history verification reports current_matches_latest true with matching hashes. From pushed state alone, two concurrent branch edits where one was discarded look exactly like two sequential edits where the second superseded the first.\n\nRECONCILE PUBLISHES THE EVIDENCE BUT DOES NOT MAKE IT GATEABLE. Running merge reconcile appends a merge_reconcile event into tracked history carrying conflict_fields, retained_hash and discarded_hash, so durable evidence can exist. But the strict health check then exits 0 in both clones, and reconcile is optional anyway. There is no configuration in which CI observes the loss.\n\nCREDIT. Greptile flagged this as P1 on every gate PR in the rollout before it merged. Verified independently and filed upstream as pm-cli 922.\n\nWHAT THE GATE ACTUALLY DOES, all observable in a fresh checkout and all worth gating on: conflict markers left in item or history files, item parse failures, invalid history JSON, history hash drift, unknown-author history events, stale in-progress work, and tracked runtime cache files. The strict-exit flag remains load-bearing because a bare health check exits 0 even when not ok.\n\nThe step is kept for that real value. Only the claim was wrong, and a gate whose documentation overstates its guarantee is worse than no gate because it stops people looking for the real hazard."}]}],"before_hash":"3e6b669bdcb0212a18649daff0cc7b83812e45039ba9357e58964bf2147d4ad2","after_hash":"5c84d74a6ac8aba5480cad952f34a3e94caaef188526c5385c110a3762e6b45f"} +{"ts":"2026-08-06T21:27:18.566Z","author":"harness:claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"db7a36e250bba74236c8424b","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-06T21:27:18.566Z"},{"op":"replace","path":"/metadata/title","value":"Gate CI on pm health for tracker integrity (does not cover discarded peer edits, see pm-cli 922)"}],"before_hash":"5c84d74a6ac8aba5480cad952f34a3e94caaef188526c5385c110a3762e6b45f","after_hash":"0aa20e7fc9faec27df3779ac98a354310d076641bc469f629f17b69b2e322afd"} {"ts":"2026-08-06T21:50:45.430Z","author":"codex","author_source":"asserted","agent_harness":"codex","agent_instance":"f1530d70463641787965c6dc","agent_provenance":{"model":null,"effort":null,"role":null,"topic":null},"op":"tests_add","patch":[{"op":"remove","path":"/metadata/tests/0/timeout_seconds"},{"op":"replace","path":"/metadata/tests/0/command","value":"npm run release:check"},{"op":"add","path":"/metadata/tests/1","value":{"command":"pm health --strict-exit","scope":"project","timeout_seconds":120}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-06T21:50:45.430Z"}],"before_hash":"0aa20e7fc9faec27df3779ac98a354310d076641bc469f629f17b69b2e322afd","after_hash":"6e8af979cfc609ec230065ced31592dbf11faa3b40e0afda4b805856ed23ebb9"} {"ts":"2026-08-06T22:00:22.077Z","author":"codex","author_source":"asserted","agent_harness":"codex","agent_instance":"f1530d70463641787965c6dc","agent_provenance":{"model":null,"effort":null,"role":null,"topic":null},"op":"files_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-06T22:00:22.077Z"},{"op":"add","path":"/metadata/files","value":[{"path":".gitattributes","scope":"project"},{"path":"package-lock.json","scope":"project"},{"path":"package.json","scope":"project"}]}],"before_hash":"6e8af979cfc609ec230065ced31592dbf11faa3b40e0afda4b805856ed23ebb9","after_hash":"232b1622591d59e550a4b6be7a2a3e3c813e141358ffc7a57bf353007009bc49"} {"ts":"2026-08-06T22:00:22.491Z","author":"codex","author_source":"asserted","agent_harness":"codex","agent_instance":"f1530d70463641787965c6dc","agent_provenance":{"model":null,"effort":null,"role":null,"topic":null},"op":"note_add","patch":[{"op":"add","path":"/metadata/notes/1","value":{"created_at":"2026-08-06T22:00:22.491Z","author":"codex","text":"Refreshed the package's development/runtime lock projection to @unbrained/pm-cli 2026.8.6 and pm-changelog 2026.8.6 where applicable, so the health and changelog gates execute against the current published contracts. Existing peer minima remain compatibility claims, not the tested tool version."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-06T22:00:22.491Z"}],"before_hash":"232b1622591d59e550a4b6be7a2a3e3c813e141358ffc7a57bf353007009bc49","after_hash":"09425406cbb6a689f7f3326d60b590a44fc25c3f7689f59bcc196d433ab43ae6"} @@ -10,3 +10,4 @@ {"ts":"2026-08-06T22:32:18.584Z","author":"codex","author_source":"asserted","agent_harness":"codex","agent_instance":"f1530d70463641787965c6dc","agent_provenance":{"model":null,"effort":null,"role":null,"topic":null},"op":"files_add","patch":[{"op":"replace","path":"/metadata/files/2/path","value":"CHANGELOG.md"},{"op":"replace","path":"/metadata/files/1/path","value":".github/workflows/ci.yml"},{"op":"add","path":"/metadata/files/3","value":{"path":"package-lock.json","scope":"project"}},{"op":"add","path":"/metadata/files/4","value":{"path":"package.json","scope":"project"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-06T22:32:18.584Z"}],"before_hash":"9b62a3b0297ff7982a01d9b0e216fc5497b20f5cb3809cd40678bf99c89d4796","after_hash":"f121c27b9b1610ac89868e220684ec94cf579c62d507f92352931e855ecf4ec9"} {"ts":"2026-08-06T22:32:19.039Z","author":"codex","author_source":"asserted","agent_harness":"codex","agent_instance":"f1530d70463641787965c6dc","agent_provenance":{"model":null,"effort":null,"role":null,"topic":null},"op":"docs_add","patch":[{"op":"add","path":"/metadata/docs/1","value":{"path":"https://github.com/unbraind/pm-cli/issues/922","scope":"global","note":"direct fresh-clone receipt and sequential-looking history shape"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-06T22:32:19.039Z"}],"before_hash":"f121c27b9b1610ac89868e220684ec94cf579c62d507f92352931e855ecf4ec9","after_hash":"de7756a4adddb71e59124adcc5a18853e6bb81bb1f66e645f650e7c0955758a8"} {"ts":"2026-08-06T22:32:19.477Z","author":"codex","author_source":"asserted","agent_harness":"codex","agent_instance":"f1530d70463641787965c6dc","agent_provenance":{"model":null,"effort":null,"role":null,"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/close_reason","value":"Added the tracked-state health gate, corrected its boundary after bot review, linked both distinct upstream blind spots (#921 repair bypass and #922 directly green fresh-clone shape), recorded every affected file, and retained local receipt reconciliation as a pre-push requirement."},{"op":"replace","path":"/metadata/actual_result","value":"Package health and release checks pass with pm 2026.8.6. Black-box proofs establish two distinct blind spots: #921 shows repair can erase the last durable signal, while #922 shows a directly green fresh-clone shape; local receipt review remains mandatory."},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-06T22:32:19.477Z"},{"op":"replace","path":"/metadata/description","value":"Run pm health --strict-exit in CI to reject durable tracker-health failures without claiming lossless merge attestation. pm-cli#921 covers history-repair erasing a durable drift signal; pm-cli#922 covers a lossy merge shape whose fresh clone is already green because receipts are local and tracked history resembles sequential edits."}],"before_hash":"de7756a4adddb71e59124adcc5a18853e6bb81bb1f66e645f650e7c0955758a8","after_hash":"55583649a5a7a1cf3908b4dea94205ea2030b1cf931eddb65a15344fdb7844aa","message":"Address exact-head bot findings about gate scope, upstream issue mapping, and affected-file evidence"} +{"ts":"2026-08-28T13:21:47.172Z","author":"claude","op":"history_repair","patch":[],"before_hash":"55583649a5a7a1cf3908b4dea94205ea2030b1cf931eddb65a15344fdb7844aa","after_hash":"55583649a5a7a1cf3908b4dea94205ea2030b1cf931eddb65a15344fdb7844aa","message":"history-repair re-anchored 0 entries.","context":{"provenance_normalization":{"changed":true,"events_changed":2,"observations_removed":2,"invalid_values":[{"harness":"claude-code","dimension":"role","kind":"single_digit","count":2}]}}} diff --git a/.agents/pm/history/pm-github-i5b8.jsonl b/.agents/pm/history/pm-github-i5b8.jsonl new file mode 100644 index 0000000..6d6c1ed --- /dev/null +++ b/.agents/pm/history/pm-github-i5b8.jsonl @@ -0,0 +1,6 @@ +{"ts":"2026-08-28T06:37:21.751Z","author":"codex","author_source":"configured","agent_harness":"pi","agent_model":"glm-5.2:cloud","agent_model_source":"environment","agent_instance":"e860458abd513c6c9e364463","agent_provenance":{"model":{"value":"glm-5.2:cloud","source":"environment"},"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"create","patch":[{"op":"replace","path":"/body","value":"The fallback was added while the registry was returning 404s, to get a release out. That trade is wrong for a supply-chain artifact: an unattested publish is not a degraded success, it is a different artifact. Failing the job leaves main holding the prepared version so the next run resumes the same release rather than inventing another one, which is exactly what the surrounding transaction was designed to do."},{"op":"add","path":"/metadata/id","value":"pm-github-i5b8"},{"op":"add","path":"/metadata/title","value":"A failed provenance publish silently falls back to an unattested one"},{"op":"add","path":"/metadata/description","value":"After three failed provenance publish attempts the release step called npm publish without --provenance and reported success. The only signal was a GitHub warning annotation. A transient registry failure therefore downgrades the package's supply-chain attestation permanently for that version, and consumers cannot tell an unattested publish caused by a 404 storm apart from one that never had provenance at all."},{"op":"add","path":"/metadata/type","value":"Issue"},{"op":"add","path":"/metadata/status","value":"open"},{"op":"add","path":"/metadata/priority","value":1},{"op":"add","path":"/metadata/tags","value":["area:release","area:supply-chain","type:defect"]},{"op":"add","path":"/metadata/created_at","value":"2026-08-28T06:37:21.751Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-08-28T06:37:21.751Z"},{"op":"add","path":"/metadata/author","value":"codex"},{"op":"add","path":"/metadata/acceptance_criteria","value":"The release step has no publish path that omits --provenance; three provenance attempts are made; a version that landed despite a reported error is reconciled instead of republished; exhausting the attempts fails the job rather than publishing unattested; the workflow YAML parses and every bash run script passes bash -n."},{"op":"add","path":"/metadata/comments","value":[{"created_at":"2026-08-28T06:37:21.751Z","author":"codex","text":"Non-vacuity proof:\nTest A (restore fallback): exit 1 — FAIL - .github/workflows/release.yml: a publish invocation does not enable --provenance, so it would publish an unattested artifact: npm publish --access public --ignore-scripts\nTest B (--provenance=false): exit 1 — FAIL - .github/workflows/release.yml: a publish invocation does not enable --provenance, so it would publish an unattested artifact: npm publish --access public --provenance=false --ignore-scripts\nTest C (clean tree): exit 0 — ok - .github/workflows/release.yml: 1 publish invocation(s), each carrying --provenance\n\nGate table:\ntypecheck: 0, coverage: 92.57/82.89/91.75, verify:release-publish-attestation: 0"}]},{"op":"add","path":"/metadata/files","value":[{"path":".github/workflows/release.yml,project,publish step refuses to downgrade attestation and reconciles a late-landing version","scope":"project"}]},{"op":"add","path":"/metadata/tests","value":[{"command":"node scripts/verify-release-publish-attestation.ts,project,120","scope":"project"}]}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"0ad260351abb75e93e82fc24e5b6d924a8c4c02a7363a805e189c0af71e121fd","item_hash_version":2,"message":""} +{"ts":"2026-08-28T06:37:26.243Z","author":"codex","author_source":"configured","agent_harness":"pi","agent_model":"glm-5.2:cloud","agent_model_source":"environment","agent_instance":"e860458abd513c6c9e364463","agent_provenance":{"model":{"value":"glm-5.2:cloud","source":"environment"},"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"close","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-28T06:37:26.243Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-08-28T06:37:26.185Z"},{"op":"add","path":"/metadata/completed_at","value":"2026-08-28T06:37:26.185Z"},{"op":"add","path":"/metadata/close_reason","value":"fixed"}],"before_hash":"0ad260351abb75e93e82fc24e5b6d924a8c4c02a7363a805e189c0af71e121fd","after_hash":"f8c26c45113acb08a80e6cc8cc0a7cae0acb5dc4e1472a9aa7f4ff7ea778acb6","item_hash_version":2} +{"ts":"2026-08-28T12:42:20.222Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"2017cd99c91bf11aa4148ca1","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"tests_remove","patch":[{"op":"remove","path":"/metadata/tests"},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-28T12:42:20.222Z"}],"before_hash":"f8c26c45113acb08a80e6cc8cc0a7cae0acb5dc4e1472a9aa7f4ff7ea778acb6","after_hash":"82f28eca1ec8e294c5c42d8d99aca4c14d6072876290c1a56d72a95b8dbc0d24","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-28T12:43:09.413Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"2017cd99c91bf11aa4148ca1","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"files_add","patch":[{"op":"remove","path":"/metadata/files"},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-28T12:43:09.413Z"}],"before_hash":"82f28eca1ec8e294c5c42d8d99aca4c14d6072876290c1a56d72a95b8dbc0d24","after_hash":"616ca25aa0f59c31cf42ee672c164cbb34b0d45f2390e4c1826406540d1c32a3","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-28T12:43:19.295Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"2017cd99c91bf11aa4148ca1","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"files_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-28T12:43:19.295Z"},{"op":"add","path":"/metadata/files","value":[{"path":".github/workflows/release.yml","scope":"project","note":"publish step refuses to downgrade attestation and reconciles a late-landing version"}]}],"before_hash":"616ca25aa0f59c31cf42ee672c164cbb34b0d45f2390e4c1826406540d1c32a3","after_hash":"70e59948cfcd110a95ba22510ea27345f6b52315c50e5e7240db63e688b79b28","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-28T12:43:19.731Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"2017cd99c91bf11aa4148ca1","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"tests_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-28T12:43:19.731Z"},{"op":"add","path":"/metadata/tests","value":[{"command":"node scripts/verify-release-publish-attestation.ts","scope":"project","timeout_seconds":120}]}],"before_hash":"70e59948cfcd110a95ba22510ea27345f6b52315c50e5e7240db63e688b79b28","after_hash":"c3f8aba42e3fce222d77054909776fa7ae4cc0c69760dcc48a6cd9b49171c854","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-rwq9.jsonl b/.agents/pm/history/pm-github-rwq9.jsonl index b691294..ec939a3 100644 --- a/.agents/pm/history/pm-github-rwq9.jsonl +++ b/.agents/pm/history/pm-github-rwq9.jsonl @@ -4,4 +4,5 @@ {"ts":"2026-08-03T22:20:57.761Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.2","agent_model_source":"environment","agent_instance":"50b044f32abe7b85e5073931","agent_provenance":{"model":{"value":"glm-5.2","source":"environment"},"effort":null,"role":null,"topic":null},"op":"note_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-03T22:20:57.761Z"},{"op":"add","path":"/metadata/notes","value":[{"created_at":"2026-08-03T22:20:57.760Z","author":"pi-agent","text":"Root cause: two defects surfaced under pm-cli 2026.8.3 close_reason enforcement. (1) SOURCE: runImport's non-atomic create path issued `pm create --status closed` for new closed GitHub issues, which is now a hard close_reason_required error. Fixed by creating closed issues as open then closing via `pm close --reason`, mirroring the already-correct atomic path and the reconciliation path. (2) FIXTURE: the test helper createLinkedItem used the same `pm create --status closed` pattern; switched to create-then-close so the closed linked-item fixture still represents a genuinely closed item. Also threaded GitHub `closed_at` through GhIssue -> PreparedGithubImport -> every close site (create path, reconciliation, atomic close op) as --completed-at / completedAt so imported items keep their real completion time instead of import time."}]}],"before_hash":"918dbd5e83e72d9be0ad6bb25ad37c34cf3e31078c171bd6e49894f291fd630b","after_hash":"464deda7e9d74ee9b89e443e472a58d8eb6c66c9f6a77ff8b09c0db4b38c3150"} {"ts":"2026-08-03T22:21:03.479Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.2","agent_model_source":"environment","agent_instance":"50b044f32abe7b85e5073931","agent_provenance":{"model":{"value":"glm-5.2","source":"environment"},"effort":null,"role":null,"topic":null},"op":"close","patch":[{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-03T22:21:03.479Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-08-03T22:21:03.466Z"},{"op":"add","path":"/metadata/completed_at","value":"2026-08-03T22:21:03.466Z"},{"op":"add","path":"/metadata/close_reason","value":"Shipped: index.ts create path now routes new closed issues through create(open)+pm close --reason [--completed-at]; reconciliation close and atomic close op carry completedAt from GitHub closed_at; createLinkedItem fixture uses create-then-close. Verified: npm run typecheck (ok), npm run build (ok), npm test (239/239 pass incl. the two previously-failing tests), npm run coverage (index.ts 88.18% lines / 79.63% branches / 89.94% funcs, all thresholds met). No governance policy weakened; closed_at is read-only provenance from the source record."}],"before_hash":"464deda7e9d74ee9b89e443e472a58d8eb6c66c9f6a77ff8b09c0db4b38c3150","after_hash":"50dcb1366c33cd61bdbcf9cf6232a2bb85d414435b82a84b72ecd957efb51802"} {"ts":"2026-08-03T23:09:31.421Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.2:cloud","agent_model_source":"environment","agent_instance":"65d9708c9f7e99938905a9c3","agent_provenance":{"model":{"value":"glm-5.2:cloud","source":"environment"},"effort":null,"role":null,"topic":null},"op":"note_add","patch":[{"op":"add","path":"/metadata/notes/1","value":{"created_at":"2026-08-03T23:09:31.421Z","author":"pi-agent","text":"Follow-up sweep found two more defects the initial fix missed. (1) PRODUCT BUG: runProjectImport (the github project import handler) still issued pm create --status closed and pm update --status closed for project board items wrapping closed issues or carrying a board Status mapped to closed. Fixed both the create path (create open, then pm close --reason with factual provenance from the wrapped issue or project ref) and the update path (update without --status, then pm close --reason). This is a user-facing bug: importing a GitHub project with closed items would fail under pm-cli 2026.8.3. (2) LATENT BUG: parseCreatedItemId looked for parsed.item.id but pm create --json emits { id: ... } at the top level, so the create-then-close path in runImport could not read the new id and silently left the item open instead of closing it. Fixed to accept both parsed.id and parsed.item.id. Added two handler-level tests (closed issue create path, closed board-status update path) and updated the parseCreatedItemId unit test for the real emit shape. All 241 tests pass; coverage thresholds met."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-03T23:09:31.421Z"}],"before_hash":"50dcb1366c33cd61bdbcf9cf6232a2bb85d414435b82a84b72ecd957efb51802","after_hash":"7280952e47bc2eff67c9fd8a42c03d340a4f3d186f0c03d5a12786d2ea242533"} -{"ts":"2026-08-03T23:54:56.259Z","author":"claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"a4c98d50b1777acea4fc909b","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"1","source":"environment"},"topic":null},"op":"claim","patch":[{"op":"replace","path":"/metadata/claim_principal","value":"claude-code"},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-03T23:54:56.259Z"},{"op":"add","path":"/metadata/assignee","value":"claude-code"}],"before_hash":"7280952e47bc2eff67c9fd8a42c03d340a4f3d186f0c03d5a12786d2ea242533","after_hash":"60d540eefe3358f272d3451deb87c37e7f92be6e8f2e8b5c3aea35f4defc5746"} +{"ts":"2026-08-03T23:54:56.259Z","author":"claude-code","author_source":"asserted","agent_harness":"claude-code","agent_instance":"a4c98d50b1777acea4fc909b","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"topic":null},"op":"claim","patch":[{"op":"replace","path":"/metadata/claim_principal","value":"claude-code"},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-03T23:54:56.259Z"},{"op":"add","path":"/metadata/assignee","value":"claude-code"}],"before_hash":"7280952e47bc2eff67c9fd8a42c03d340a4f3d186f0c03d5a12786d2ea242533","after_hash":"60d540eefe3358f272d3451deb87c37e7f92be6e8f2e8b5c3aea35f4defc5746"} +{"ts":"2026-08-28T13:21:47.188Z","author":"claude","op":"history_repair","patch":[],"before_hash":"60d540eefe3358f272d3451deb87c37e7f92be6e8f2e8b5c3aea35f4defc5746","after_hash":"60d540eefe3358f272d3451deb87c37e7f92be6e8f2e8b5c3aea35f4defc5746","message":"history-repair re-anchored 0 entries.","context":{"provenance_normalization":{"changed":true,"events_changed":1,"observations_removed":1,"invalid_values":[{"harness":"claude-code","dimension":"role","kind":"single_digit","count":1}]}}} diff --git a/.agents/pm/history/pm-github-v2kt.jsonl b/.agents/pm/history/pm-github-v2kt.jsonl index 8a132d9..72773a3 100644 --- a/.agents/pm/history/pm-github-v2kt.jsonl +++ b/.agents/pm/history/pm-github-v2kt.jsonl @@ -1,11 +1,12 @@ -{"ts":"2026-08-10T15:27:52.482Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.2:cloud","agent_model_source":"environment","agent_instance":"535c4c4bbb093654ea2cf13c","agent_provenance":{"model":{"value":"glm-5.2:cloud","source":"environment"},"effort":null,"role":null,"topic":null},"op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-github-v2kt"},{"op":"add","path":"/metadata/title","value":"Fix release publish ordering ahead of protected main push"},{"op":"add","path":"/metadata/description","value":"The daily release workflow published to npm and then pushed the version bump straight to a protected main branch. Branch protection rejected that push with GH006, and the job's fail-fast shell mode killed it before the tag push, so npm ended up ahead of git: main stayed on an older version with no matching tag. The release metadata is now merged through a protected PR before npm publish runs, and only the release tag is pushed after a successful publish. Ported from the verified fix in pm-beads."},{"op":"add","path":"/metadata/type","value":"Issue"},{"op":"add","path":"/metadata/status","value":"open"},{"op":"add","path":"/metadata/priority","value":2},{"op":"add","path":"/metadata/tags","value":[]},{"op":"add","path":"/metadata/created_at","value":"2026-08-10T15:27:52.482Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-08-10T15:27:52.482Z"},{"op":"add","path":"/metadata/author","value":"pi-agent"}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"f44791f47d30aed6d9a71f17d902212f1d835d3fd27d28ba1b63456dd0281231","message":""} -{"ts":"2026-08-10T15:27:52.883Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.2:cloud","agent_model_source":"environment","agent_instance":"535c4c4bbb093654ea2cf13c","agent_provenance":{"model":{"value":"glm-5.2:cloud","source":"environment"},"effort":null,"role":null,"topic":null},"op":"note_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-10T15:27:52.883Z"},{"op":"add","path":"/metadata/notes","value":[{"created_at":"2026-08-10T15:27:52.883Z","author":"pi-agent","text":"Applied the pm-beads release ordering fix to .github/workflows/release.yml: added pull-requests write permission, captured base_sha in the decide step, and replaced the publish-then-push-to-main steps with a protected-PR merge, a verify-merged-release check, the npm publish (with idempotence guard), and a tag-only push. dist is gitignored here so it is excluded from the verify diff. Gates npm ci, build, test, release:check and yaml all pass."}]}],"before_hash":"f44791f47d30aed6d9a71f17d902212f1d835d3fd27d28ba1b63456dd0281231","after_hash":"4ca4b9a8e0a17de8ffa66feb82066420e4f8c2e7c002292248f3decbde5cc3fc"} -{"ts":"2026-08-10T15:30:44.660Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"304c86ad9f9d7cbae7f44c3b","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"1","source":"environment"},"topic":null},"op":"note_add","patch":[{"op":"add","path":"/metadata/notes/1","value":{"created_at":"2026-08-10T15:30:44.660Z","author":"claude","text":"Greptile reported that creating the release pull request with GITHUB_TOKEN suppresses its pull_request event so CI never starts. Measured against pm-changelog, the event is not suppressed: a run was created at 04:54:24Z with event pull_request for branch release/2026.8.9. The real blocker is that attempt 1 concluded action_required, meaning GitHub parked the run awaiting workflow approval under the fork pull request contributor approval policy of first_time_contributors, because github-actions had no merged pull request in that repository yet. A parked run never appears in the status check rollup, so it is indistinguishable from a required check that failed. The merge wait now detects parked runs, reports the run URL, attempts approval using the actions write permission, and separates awaiting approval from a failed check in the deadline error."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-10T15:30:44.660Z"}],"before_hash":"4ca4b9a8e0a17de8ffa66feb82066420e4f8c2e7c002292248f3decbde5cc3fc","after_hash":"489e4f927f5a520731d4d3db368e9b51a39c3ff0343a479fbc9a294e99548eaf"} -{"ts":"2026-08-10T15:55:22.540Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"304c86ad9f9d7cbae7f44c3b","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"1","source":"environment"},"topic":null},"op":"note_add","patch":[{"op":"add","path":"/metadata/notes/2","value":{"created_at":"2026-08-10T15:55:22.540Z","author":"claude","text":"Round two review fixes. CodeRabbit found that the Push release tag step consulted only the local tag database, while the last tag fetch happens back in Decide release, so a tag created on origin in between would be missed, git tag would succeed locally and the push would be rejected as non-fast-forward after a successful publish, which is exactly the npm ahead of git state this work removes. The step now fetches tags and compares against the remote tag target, exiting cleanly when the tag already points at the verified commit. CodeRabbit also found that the dist entry in the verification diff verified nothing, because nothing rebuilds before it so git diff compared dist against itself and always passed, and git diff cannot see untracked or orphaned artifacts. Where dist is tracked the step now rebuilds from clean and uses git status porcelain with untracked files included. Reproducibility was confirmed locally in every repository that tracks dist before shipping the check."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-10T15:55:22.540Z"}],"before_hash":"489e4f927f5a520731d4d3db368e9b51a39c3ff0343a479fbc9a294e99548eaf","after_hash":"d18bf7219a7db06356b24f76adfa5a43c63714e1f9fe8b0ccfb1c563e436c837"} -{"ts":"2026-08-10T16:03:35.223Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"304c86ad9f9d7cbae7f44c3b","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"1","source":"environment"},"topic":null},"op":"note_add","patch":[{"op":"add","path":"/metadata/notes/3","value":{"created_at":"2026-08-10T16:03:35.223Z","author":"claude","text":"CodeRabbit found a security regression in the thread resolution I added. The loop resolved every unresolved review thread on the release pull request, which would also clear a human reviewer's blocking thread and remove exactly the protection required_conversation_resolution provides for release commits. The GraphQL query now selects the first comment's author type and only bot authored threads are resolved. The jq filter was verified against a sample payload containing one bot thread and one human thread, and it returned only the bot thread."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-10T16:03:35.223Z"}],"before_hash":"d18bf7219a7db06356b24f76adfa5a43c63714e1f9fe8b0ccfb1c563e436c837","after_hash":"2042d4ef91b97c5533970b13e101ea72b5bb5d1b5430fe11c41f1428dfeb0718"} -{"ts":"2026-08-10T16:14:01.418Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"304c86ad9f9d7cbae7f44c3b","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"1","source":"environment"},"topic":null},"op":"note_add","patch":[{"op":"add","path":"/metadata/notes/4","value":{"created_at":"2026-08-10T16:14:01.418Z","author":"claude","text":"Round four review fixes. Greptile found that classifying a review thread by its first comment lets a bot opened thread with a substantive human reply be auto resolved, bypassing the human concern. A thread now counts as advisory only when every comment on it is bot authored, and threads with no comments are excluded. Verified against four thread shapes, all bot, bot then human, human only, and empty, selecting only the all bot thread. CodeRabbit found the dist rebuild check omitted ignored matching so newly generated ignored artifacts under dist would stay hidden."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-10T16:14:01.418Z"}],"before_hash":"2042d4ef91b97c5533970b13e101ea72b5bb5d1b5430fe11c41f1428dfeb0718","after_hash":"3c3705b49ccf9e87c5c38880c9f76b3a952bc615f9f98c5b67b43a33fad89a3b"} -{"ts":"2026-08-10T16:23:19.099Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"304c86ad9f9d7cbae7f44c3b","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"1","source":"environment"},"topic":null},"op":"note_add","patch":[{"op":"add","path":"/metadata/notes/5","value":{"created_at":"2026-08-10T16:23:19.099Z","author":"claude","text":"Round five review fixes. CodeRabbit found that the tracking note claimed dist validation that pm-web's workflow did not actually contain. An audit showed seven repositories track dist but had no rebuild check, because the earlier patch keyed on a diff paths idiom those repositories do not use: pm-changelog, pm-context, pm-gantt-chart, pm-graph, pm-jira, pm-ops and pm-web. All seven now rebuild dist from clean and compare with git status including untracked and ignored files, and reproducibility was confirmed locally in each before enabling the check. Greptile found that fetching only the first hundred thread comments lets a human reply past that page escape the all bot predicate, so the filter now refuses to resolve any thread whose comment total exceeds the comments actually returned. Verified against a truncated thread with a total of one hundred fifty and two fetched nodes, which is correctly not resolved."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-10T16:23:19.099Z"}],"before_hash":"3c3705b49ccf9e87c5c38880c9f76b3a952bc615f9f98c5b67b43a33fad89a3b","after_hash":"9efe94e308a4cc092b2ee8bc790c2f8a59f3bb9925bfe2e5c5e54332972e336d"} -{"ts":"2026-08-10T16:41:09.417Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"304c86ad9f9d7cbae7f44c3b","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"1","source":"environment"},"topic":null},"op":"note_add","patch":[{"op":"add","path":"/metadata/notes/6","value":{"created_at":"2026-08-10T16:41:09.417Z","author":"claude","text":"CodeRabbit reported that git ls-remote --tags returns both the tag object and its peeled commit for an annotated tag, so cut -f1 would yield two SHAs and git rev-list would fail. Tested against a real annotated tag pushed to a local bare remote. The peeled entry appears when ls-remote is called with no pattern or with a glob, but NOT with the exact refspec form this workflow uses, which returned a single line. The finding therefore does not reproduce against our call. The suggested form was adopted anyway because it costs nothing, states the intent explicitly, and keeps the code correct if the pattern is ever loosened to a glob."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-10T16:41:09.417Z"}],"before_hash":"9efe94e308a4cc092b2ee8bc790c2f8a59f3bb9925bfe2e5c5e54332972e336d","after_hash":"3bfec546b57470fc9666cf1d55906411f8350b7988f4d966b0f8451e2e054341"} -{"ts":"2026-08-10T16:57:16.322Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"304c86ad9f9d7cbae7f44c3b","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"1","source":"environment"},"topic":null},"op":"note_add","patch":[{"op":"add","path":"/metadata/notes/7","value":{"created_at":"2026-08-10T16:57:16.322Z","author":"claude","text":"Round six follow up. CodeRabbit noted the parked run detection reads only the first page of the workflow runs endpoint, whose default page size is thirty, so a parked run past that page would go undetected. The query now requests one hundred per page. This is a diagnostic path rather than a safety one, since an undetected parked run simply means the release is not approved and times out without publishing, but the larger page costs nothing. Thread level pagination was declined separately because unfetched threads are never resolved and therefore keep blocking, which is the safe direction."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-10T16:57:16.322Z"}],"before_hash":"3bfec546b57470fc9666cf1d55906411f8350b7988f4d966b0f8451e2e054341","after_hash":"ccb2819974d107822f82a38d930bb36e486d490bb0ed619c32057eb6e86bf0bb"} +{"ts":"2026-08-10T15:27:52.482Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.2:cloud","agent_model_source":"environment","agent_instance":"535c4c4bbb093654ea2cf13c","agent_provenance":{"model":{"value":"glm-5.2:cloud","source":"environment"},"effort":null,"role":null,"topic":null},"op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-github-v2kt"},{"op":"add","path":"/metadata/title","value":"Fix release publish ordering ahead of protected main push"},{"op":"add","path":"/metadata/description","value":"The daily release workflow published to npm and then pushed the version bump straight to a protected main branch. Branch protection rejected that push with GH006, and the job's fail-fast shell mode killed it before the tag push, so npm ended up ahead of git: main stayed on an older version with no matching tag. The release metadata is now merged through a protected PR before npm publish runs, and only the release tag is pushed after a successful publish. Ported from the verified fix in pm-beads."},{"op":"add","path":"/metadata/type","value":"Issue"},{"op":"add","path":"/metadata/status","value":"open"},{"op":"add","path":"/metadata/priority","value":2},{"op":"add","path":"/metadata/tags","value":[]},{"op":"add","path":"/metadata/created_at","value":"2026-08-10T15:27:52.482Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-08-10T15:27:52.482Z"},{"op":"add","path":"/metadata/author","value":"pi-agent"}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"f44791f47d30aed6d9a71f17d902212f1d835d3fd27d28ba1b63456dd0281231","message":"","item_hash_version":2} +{"ts":"2026-08-10T15:27:52.883Z","author":"pi-agent","author_source":"asserted","agent_harness":"pi","agent_model":"glm-5.2:cloud","agent_model_source":"environment","agent_instance":"535c4c4bbb093654ea2cf13c","agent_provenance":{"model":{"value":"glm-5.2:cloud","source":"environment"},"effort":null,"role":null,"topic":null},"op":"note_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-10T15:27:52.883Z"},{"op":"add","path":"/metadata/notes","value":[{"created_at":"2026-08-10T15:27:52.883Z","author":"pi-agent","text":"Applied the pm-beads release ordering fix to .github/workflows/release.yml: added pull-requests write permission, captured base_sha in the decide step, and replaced the publish-then-push-to-main steps with a protected-PR merge, a verify-merged-release check, the npm publish (with idempotence guard), and a tag-only push. dist is gitignored here so it is excluded from the verify diff. Gates npm ci, build, test, release:check and yaml all pass."}]}],"before_hash":"f44791f47d30aed6d9a71f17d902212f1d835d3fd27d28ba1b63456dd0281231","after_hash":"4ca4b9a8e0a17de8ffa66feb82066420e4f8c2e7c002292248f3decbde5cc3fc","item_hash_version":2} +{"ts":"2026-08-10T15:30:44.660Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"304c86ad9f9d7cbae7f44c3b","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"topic":null},"op":"note_add","patch":[{"op":"add","path":"/metadata/notes/1","value":{"created_at":"2026-08-10T15:30:44.660Z","author":"claude","text":"Greptile reported that creating the release pull request with GITHUB_TOKEN suppresses its pull_request event so CI never starts. Measured against pm-changelog, the event is not suppressed: a run was created at 04:54:24Z with event pull_request for branch release/2026.8.9. The real blocker is that attempt 1 concluded action_required, meaning GitHub parked the run awaiting workflow approval under the fork pull request contributor approval policy of first_time_contributors, because github-actions had no merged pull request in that repository yet. A parked run never appears in the status check rollup, so it is indistinguishable from a required check that failed. The merge wait now detects parked runs, reports the run URL, attempts approval using the actions write permission, and separates awaiting approval from a failed check in the deadline error."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-10T15:30:44.660Z"}],"before_hash":"4ca4b9a8e0a17de8ffa66feb82066420e4f8c2e7c002292248f3decbde5cc3fc","after_hash":"489e4f927f5a520731d4d3db368e9b51a39c3ff0343a479fbc9a294e99548eaf","item_hash_version":2} +{"ts":"2026-08-10T15:55:22.540Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"304c86ad9f9d7cbae7f44c3b","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"topic":null},"op":"note_add","patch":[{"op":"add","path":"/metadata/notes/2","value":{"created_at":"2026-08-10T15:55:22.540Z","author":"claude","text":"Round two review fixes. CodeRabbit found that the Push release tag step consulted only the local tag database, while the last tag fetch happens back in Decide release, so a tag created on origin in between would be missed, git tag would succeed locally and the push would be rejected as non-fast-forward after a successful publish, which is exactly the npm ahead of git state this work removes. The step now fetches tags and compares against the remote tag target, exiting cleanly when the tag already points at the verified commit. CodeRabbit also found that the dist entry in the verification diff verified nothing, because nothing rebuilds before it so git diff compared dist against itself and always passed, and git diff cannot see untracked or orphaned artifacts. Where dist is tracked the step now rebuilds from clean and uses git status porcelain with untracked files included. Reproducibility was confirmed locally in every repository that tracks dist before shipping the check."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-10T15:55:22.540Z"}],"before_hash":"489e4f927f5a520731d4d3db368e9b51a39c3ff0343a479fbc9a294e99548eaf","after_hash":"d18bf7219a7db06356b24f76adfa5a43c63714e1f9fe8b0ccfb1c563e436c837","item_hash_version":2} +{"ts":"2026-08-10T16:03:35.223Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"304c86ad9f9d7cbae7f44c3b","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"topic":null},"op":"note_add","patch":[{"op":"add","path":"/metadata/notes/3","value":{"created_at":"2026-08-10T16:03:35.223Z","author":"claude","text":"CodeRabbit found a security regression in the thread resolution I added. The loop resolved every unresolved review thread on the release pull request, which would also clear a human reviewer's blocking thread and remove exactly the protection required_conversation_resolution provides for release commits. The GraphQL query now selects the first comment's author type and only bot authored threads are resolved. The jq filter was verified against a sample payload containing one bot thread and one human thread, and it returned only the bot thread."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-10T16:03:35.223Z"}],"before_hash":"d18bf7219a7db06356b24f76adfa5a43c63714e1f9fe8b0ccfb1c563e436c837","after_hash":"2042d4ef91b97c5533970b13e101ea72b5bb5d1b5430fe11c41f1428dfeb0718","item_hash_version":2} +{"ts":"2026-08-10T16:14:01.418Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"304c86ad9f9d7cbae7f44c3b","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"topic":null},"op":"note_add","patch":[{"op":"add","path":"/metadata/notes/4","value":{"created_at":"2026-08-10T16:14:01.418Z","author":"claude","text":"Round four review fixes. Greptile found that classifying a review thread by its first comment lets a bot opened thread with a substantive human reply be auto resolved, bypassing the human concern. A thread now counts as advisory only when every comment on it is bot authored, and threads with no comments are excluded. Verified against four thread shapes, all bot, bot then human, human only, and empty, selecting only the all bot thread. CodeRabbit found the dist rebuild check omitted ignored matching so newly generated ignored artifacts under dist would stay hidden."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-10T16:14:01.418Z"}],"before_hash":"2042d4ef91b97c5533970b13e101ea72b5bb5d1b5430fe11c41f1428dfeb0718","after_hash":"3c3705b49ccf9e87c5c38880c9f76b3a952bc615f9f98c5b67b43a33fad89a3b","item_hash_version":2} +{"ts":"2026-08-10T16:23:19.099Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"304c86ad9f9d7cbae7f44c3b","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"topic":null},"op":"note_add","patch":[{"op":"add","path":"/metadata/notes/5","value":{"created_at":"2026-08-10T16:23:19.099Z","author":"claude","text":"Round five review fixes. CodeRabbit found that the tracking note claimed dist validation that pm-web's workflow did not actually contain. An audit showed seven repositories track dist but had no rebuild check, because the earlier patch keyed on a diff paths idiom those repositories do not use: pm-changelog, pm-context, pm-gantt-chart, pm-graph, pm-jira, pm-ops and pm-web. All seven now rebuild dist from clean and compare with git status including untracked and ignored files, and reproducibility was confirmed locally in each before enabling the check. Greptile found that fetching only the first hundred thread comments lets a human reply past that page escape the all bot predicate, so the filter now refuses to resolve any thread whose comment total exceeds the comments actually returned. Verified against a truncated thread with a total of one hundred fifty and two fetched nodes, which is correctly not resolved."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-10T16:23:19.099Z"}],"before_hash":"3c3705b49ccf9e87c5c38880c9f76b3a952bc615f9f98c5b67b43a33fad89a3b","after_hash":"9efe94e308a4cc092b2ee8bc790c2f8a59f3bb9925bfe2e5c5e54332972e336d","item_hash_version":2} +{"ts":"2026-08-10T16:41:09.417Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"304c86ad9f9d7cbae7f44c3b","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"topic":null},"op":"note_add","patch":[{"op":"add","path":"/metadata/notes/6","value":{"created_at":"2026-08-10T16:41:09.417Z","author":"claude","text":"CodeRabbit reported that git ls-remote --tags returns both the tag object and its peeled commit for an annotated tag, so cut -f1 would yield two SHAs and git rev-list would fail. Tested against a real annotated tag pushed to a local bare remote. The peeled entry appears when ls-remote is called with no pattern or with a glob, but NOT with the exact refspec form this workflow uses, which returned a single line. The finding therefore does not reproduce against our call. The suggested form was adopted anyway because it costs nothing, states the intent explicitly, and keeps the code correct if the pattern is ever loosened to a glob."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-10T16:41:09.417Z"}],"before_hash":"9efe94e308a4cc092b2ee8bc790c2f8a59f3bb9925bfe2e5c5e54332972e336d","after_hash":"3bfec546b57470fc9666cf1d55906411f8350b7988f4d966b0f8451e2e054341","item_hash_version":2} +{"ts":"2026-08-10T16:57:16.322Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"304c86ad9f9d7cbae7f44c3b","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"topic":null},"op":"note_add","patch":[{"op":"add","path":"/metadata/notes/7","value":{"created_at":"2026-08-10T16:57:16.322Z","author":"claude","text":"Round six follow up. CodeRabbit noted the parked run detection reads only the first page of the workflow runs endpoint, whose default page size is thirty, so a parked run past that page would go undetected. The query now requests one hundred per page. This is a diagnostic path rather than a safety one, since an undetected parked run simply means the release is not approved and times out without publishing, but the larger page costs nothing. Thread level pagination was declined separately because unfetched threads are never resolved and therefore keep blocking, which is the safe direction."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-08-10T16:57:16.322Z"}],"before_hash":"3bfec546b57470fc9666cf1d55906411f8350b7988f4d966b0f8451e2e054341","after_hash":"ccb2819974d107822f82a38d930bb36e486d490bb0ed619c32057eb6e86bf0bb","item_hash_version":2} {"ts":"2026-08-21T21:31:12.813Z","author":"ox-alpha","author_source":"asserted","agent_harness":"pi","agent_model":"stealth/ox-alpha","agent_model_source":"environment","agent_instance":"154a23dd8a71b7193a1b854c","agent_provenance":{"model":{"value":"stealth/ox-alpha","source":"environment"},"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"close","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-21T21:31:12.813Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-08-21T21:31:12.798Z"},{"op":"add","path":"/metadata/completed_at","value":"2026-08-21T21:31:12.798Z"},{"op":"add","path":"/metadata/resolution","value":"Release publish ordering fixed and merged to main via PR #46: release metadata merges through a protected PR before npm publish; only the release tag pushes after a verified publish; idempotence guard reconciles already-published versions."},{"op":"add","path":"/metadata/close_reason","value":"Fix verified live on origin/main (workflow lines: pull-requests:write, base_sha capture at decide, protected release-PR merge with parked-run approval handling, idempotent npm publish with 3-attempt provenance fallback, tag-only push after successful publish; never pushes HEAD:main again). Merged via PR #46; six bot review rounds resolved in notes. npm-ahead-of-git state eliminated."}],"before_hash":"ccb2819974d107822f82a38d930bb36e486d490bb0ed619c32057eb6e86bf0bb","after_hash":"d5576ac881a04d4027c16c7b446b527259b1fa49cfef0eb3258175a3baeae699","item_hash_version":2} {"ts":"2026-08-21T21:31:24.293Z","author":"ox-alpha","author_source":"asserted","agent_harness":"pi","agent_model":"stealth/ox-alpha","agent_model_source":"environment","agent_instance":"154a23dd8a71b7193a1b854c","agent_provenance":{"model":{"value":"stealth/ox-alpha","source":"environment"},"effort":null,"role":{"value":"implementer","source":"argv"},"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-21T21:31:24.293Z"},{"op":"add","path":"/metadata/expected_result","value":"npm publish and git main/tag state always reconcile: release metadata lands on protected main through a PR before publish; only the tag pushes after a verified publish; re-runs are idempotent"},{"op":"add","path":"/metadata/actual_result","value":"Verified on origin/main: protected-PR merge flow with parked-run approval detection, idempotent publish guard with 3-attempt provenance fallback, tag-only post-publish push; merged in PR #46"}],"before_hash":"d5576ac881a04d4027c16c7b446b527259b1fa49cfef0eb3258175a3baeae699","after_hash":"faf450b3ab9e71f60ba081f77d98c61915cec1174a8984576befea79c5ed9cea","item_hash_version":2} +{"ts":"2026-08-28T13:21:47.204Z","author":"claude","op":"history_repair","patch":[],"before_hash":"faf450b3ab9e71f60ba081f77d98c61915cec1174a8984576befea79c5ed9cea","after_hash":"faf450b3ab9e71f60ba081f77d98c61915cec1174a8984576befea79c5ed9cea","message":"history-repair re-anchored 0 entries.","item_hash_version":2,"context":{"provenance_normalization":{"changed":true,"events_changed":7,"observations_removed":7,"invalid_values":[{"harness":"claude-code","dimension":"role","kind":"single_digit","count":7}]}}} diff --git a/.agents/pm/history/pm-github-wxob.jsonl b/.agents/pm/history/pm-github-wxob.jsonl index a7854af..2268505 100644 --- a/.agents/pm/history/pm-github-wxob.jsonl +++ b/.agents/pm/history/pm-github-wxob.jsonl @@ -1,3 +1,4 @@ -{"ts":"2026-08-09T12:55:45.761Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"6c62aab177c939482aa0465b","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"1","source":"environment"},"topic":null},"op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-github-wxob"},{"op":"add","path":"/metadata/title","value":"The mandatory docstring gate could skip its own scan and still exit zero"},{"op":"add","path":"/metadata/description","value":"isMainInvocation resolved this module's own path inside a try block that swallowed every failure and returned false. Returning false leaves main() unreached and process.exitCode at zero, so a release could pass the mandatory docstring gate having scanned nothing. The two resolutions now differ: an unresolvable argv[1] still answers false because that is the ordinary imported-by-a-test case, while an unresolvable own module path throws. A regression test asserts the throw and was mutation-checked by reverting the fix, which makes it fail."},{"op":"add","path":"/metadata/type","value":"Issue"},{"op":"add","path":"/metadata/status","value":"open"},{"op":"add","path":"/metadata/priority","value":2},{"op":"add","path":"/metadata/tags","value":[]},{"op":"add","path":"/metadata/created_at","value":"2026-08-09T12:55:45.761Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-08-09T12:55:45.761Z"},{"op":"add","path":"/metadata/author","value":"claude"}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"cbba035fac2e8093d751f8801353f61d1cba0e640b5700fc2bd82f4453bb5dc2","message":""} -{"ts":"2026-08-09T12:57:17.194Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"6c62aab177c939482aa0465b","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"1","source":"environment"},"topic":null},"op":"note_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-09T12:57:17.194Z"},{"op":"add","path":"/metadata/notes","value":[{"created_at":"2026-08-09T12:57:17.194Z","author":"claude","text":"Mutation-checked in pm-slack by reverting the split resolution back to the single try block, which makes the new test fail; the identical fix and test ship here."}]}],"before_hash":"cbba035fac2e8093d751f8801353f61d1cba0e640b5700fc2bd82f4453bb5dc2","after_hash":"0dbf2be861ce10ec753092478c12e94273c68439e6445cf4aecb4fc3c5fcb56f"} -{"ts":"2026-08-09T12:57:17.626Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"6c62aab177c939482aa0465b","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":{"value":"1","source":"environment"},"topic":null},"op":"close","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-09T12:57:17.626Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-08-09T12:57:17.611Z"},{"op":"add","path":"/metadata/completed_at","value":"2026-08-09T12:57:17.611Z"},{"op":"add","path":"/metadata/resolution","value":"fixed"},{"op":"add","path":"/metadata/expected_result","value":"A docstring gate whose own path cannot be resolved must fail the release rather than exit zero having scanned nothing."},{"op":"add","path":"/metadata/actual_result","value":"isMainInvocation throws on self-resolution failure; a regression test asserts the throw and the suite passes."},{"op":"add","path":"/metadata/close_reason","value":"Split the two path resolutions so only an unresolvable argv[1] answers false; an unresolvable own module path now throws."}],"before_hash":"0dbf2be861ce10ec753092478c12e94273c68439e6445cf4aecb4fc3c5fcb56f","after_hash":"9af057081e9104f8a71e86fdef654556b14764f50ab40d824bbf5c02af306522"} +{"ts":"2026-08-09T12:55:45.761Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"6c62aab177c939482aa0465b","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"topic":null},"op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-github-wxob"},{"op":"add","path":"/metadata/title","value":"The mandatory docstring gate could skip its own scan and still exit zero"},{"op":"add","path":"/metadata/description","value":"isMainInvocation resolved this module's own path inside a try block that swallowed every failure and returned false. Returning false leaves main() unreached and process.exitCode at zero, so a release could pass the mandatory docstring gate having scanned nothing. The two resolutions now differ: an unresolvable argv[1] still answers false because that is the ordinary imported-by-a-test case, while an unresolvable own module path throws. A regression test asserts the throw and was mutation-checked by reverting the fix, which makes it fail."},{"op":"add","path":"/metadata/type","value":"Issue"},{"op":"add","path":"/metadata/status","value":"open"},{"op":"add","path":"/metadata/priority","value":2},{"op":"add","path":"/metadata/tags","value":[]},{"op":"add","path":"/metadata/created_at","value":"2026-08-09T12:55:45.761Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-08-09T12:55:45.761Z"},{"op":"add","path":"/metadata/author","value":"claude"}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"cbba035fac2e8093d751f8801353f61d1cba0e640b5700fc2bd82f4453bb5dc2","message":""} +{"ts":"2026-08-09T12:57:17.194Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"6c62aab177c939482aa0465b","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"topic":null},"op":"note_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-09T12:57:17.194Z"},{"op":"add","path":"/metadata/notes","value":[{"created_at":"2026-08-09T12:57:17.194Z","author":"claude","text":"Mutation-checked in pm-slack by reverting the split resolution back to the single try block, which makes the new test fail; the identical fix and test ship here."}]}],"before_hash":"cbba035fac2e8093d751f8801353f61d1cba0e640b5700fc2bd82f4453bb5dc2","after_hash":"0dbf2be861ce10ec753092478c12e94273c68439e6445cf4aecb4fc3c5fcb56f"} +{"ts":"2026-08-09T12:57:17.626Z","author":"claude","author_source":"asserted","agent_harness":"claude-code","agent_instance":"6c62aab177c939482aa0465b","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"topic":null},"op":"close","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-09T12:57:17.626Z"},{"op":"replace","path":"/metadata/status","value":"closed"},{"op":"add","path":"/metadata/closed_at","value":"2026-08-09T12:57:17.611Z"},{"op":"add","path":"/metadata/completed_at","value":"2026-08-09T12:57:17.611Z"},{"op":"add","path":"/metadata/resolution","value":"fixed"},{"op":"add","path":"/metadata/expected_result","value":"A docstring gate whose own path cannot be resolved must fail the release rather than exit zero having scanned nothing."},{"op":"add","path":"/metadata/actual_result","value":"isMainInvocation throws on self-resolution failure; a regression test asserts the throw and the suite passes."},{"op":"add","path":"/metadata/close_reason","value":"Split the two path resolutions so only an unresolvable argv[1] answers false; an unresolvable own module path now throws."}],"before_hash":"0dbf2be861ce10ec753092478c12e94273c68439e6445cf4aecb4fc3c5fcb56f","after_hash":"9af057081e9104f8a71e86fdef654556b14764f50ab40d824bbf5c02af306522"} +{"ts":"2026-08-28T13:21:47.215Z","author":"claude","op":"history_repair","patch":[],"before_hash":"9af057081e9104f8a71e86fdef654556b14764f50ab40d824bbf5c02af306522","after_hash":"9af057081e9104f8a71e86fdef654556b14764f50ab40d824bbf5c02af306522","message":"history-repair re-anchored 0 entries.","context":{"provenance_normalization":{"changed":true,"events_changed":3,"observations_removed":3,"invalid_values":[{"harness":"claude-code","dimension":"role","kind":"single_digit","count":3}]}}} diff --git a/.agents/pm/history/pm-github-ypi5.jsonl b/.agents/pm/history/pm-github-ypi5.jsonl new file mode 100644 index 0000000..9deada8 --- /dev/null +++ b/.agents/pm/history/pm-github-ypi5.jsonl @@ -0,0 +1,3 @@ +{"ts":"2026-08-28T12:38:13.873Z","author":"pi-agent","author_source":"asserted","agent_harness":"claude-code","agent_instance":"2017cd99c91bf11aa4148ca1","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-ypi5"},{"op":"add","path":"/metadata/title","value":"The daily release cannot build a changelog once the tracker outgrows the default output budget"},{"op":"add","path":"/metadata/description","value":"The release job failed at the Generate changelog and release notes step with pm list-all --json answer was incomplete and was refused, count=5 of total=86. The repository pins pm-changelog through a caret range whose lockfile still resolved 2026.8.17, and that version reads the whole tracker with a plain whole-tracker list and does not pass the unbounded output-budget and output-limit controls that 2026.8.22 added. npx prefers the locally installed copy over the registry, so the job ran 2026.8.17 regardless of what npm served. With 89 items this repository crossed the default output budget, the read came back truncated, and pm-changelog correctly refused to build a changelog from a partial workspace rather than silently omitting entries. The refusal is right; the stale pin is the defect."},{"op":"add","path":"/metadata/type","value":"Issue"},{"op":"add","path":"/metadata/status","value":"open"},{"op":"add","path":"/metadata/priority","value":2},{"op":"add","path":"/metadata/tags","value":[]},{"op":"add","path":"/metadata/created_at","value":"2026-08-28T12:38:13.873Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-08-28T12:38:13.873Z"},{"op":"add","path":"/metadata/author","value":"pi-agent"}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"9a11d1455ca890526a781e661c4e36bbf4f3300d02170336c1a5a686dd659f50","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-28T12:38:26.016Z","author":"pi-agent","author_source":"asserted","agent_harness":"claude-code","agent_instance":"2017cd99c91bf11aa4148ca1","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-28T12:38:26.016Z"},{"op":"add","path":"/metadata/tags/0","value":"area:changelog"},{"op":"add","path":"/metadata/tags/1","value":"area:release"},{"op":"add","path":"/metadata/tags/2","value":"type:defect"},{"op":"replace","path":"/metadata/priority","value":0},{"op":"add","path":"/metadata/acceptance_criteria","value":"The lockfile resolves pm-changelog 2026.8.22 or newer; the exact argv the release workflow runs for generation and for check both exit zero against the full 89 item tracker; and the installed copy is proven by reading its package.json version rather than by the range in package.json."},{"op":"add","path":"/metadata/risk","value":"critical"},{"op":"add","path":"/metadata/severity","value":"critical"},{"op":"add","path":"/metadata/expected_result","value":"The release reads the entire tracker and generates a complete changelog no matter how many items the repository has accumulated."},{"op":"add","path":"/metadata/actual_result","value":"The changelog step exits non-zero for any repository whose tracker exceeds the default output budget."},{"op":"add","path":"/metadata/affected_version","value":"pm-changelog 2026.8.17 as locked before this change"},{"op":"add","path":"/metadata/component","value":"package.json devDependency on pm-changelog"}],"before_hash":"9a11d1455ca890526a781e661c4e36bbf4f3300d02170336c1a5a686dd659f50","after_hash":"5396247be83c51c4d10f6ce5e8d73dd63191051eb42ee35c2fa224a998c3f680","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-28T12:38:27.413Z","author":"pi-agent","author_source":"asserted","agent_harness":"claude-code","agent_instance":"2017cd99c91bf11aa4148ca1","agent_provenance":{"model":null,"effort":{"value":"xhigh","source":"environment"},"role":null,"topic":null},"op":"docs_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-08-28T12:38:27.413Z"},{"op":"add","path":"/metadata/docs","value":[{"path":"package.json","scope":"project","note":"pm-changelog devDependency raised to the version carrying the unbounded whole-tracker read"}]}],"before_hash":"5396247be83c51c4d10f6ce5e8d73dd63191051eb42ee35c2fa224a998c3f680","after_hash":"1089cdb6213e2b579c917c357ebac462e9e436a7535ead67f65599b3c4a3926b","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/issues/pm-github-5igz.toon b/.agents/pm/issues/pm-github-5igz.toon new file mode 100644 index 0000000..8feb8b1 --- /dev/null +++ b/.agents/pm/issues/pm-github-5igz.toon @@ -0,0 +1,29 @@ +id: pm-github-5igz +title: "A publish spelled through a package runner was invisible to the attestation gate, so an unattested one read as clean" +description: "The attestation verifier tokenises each shell segment and treats the first non-runner word as the executable. Package runners were absent from that skip list, so in npx npm publish the executable resolved to npx and the segment was not recognised as a publish at all. An unrecognised publish is never checked for the provenance flag, and the failure hides itself: the workflow's ordinary attested publish still satisfies the non-vacuity guard, so the gate reported clean while an unattested publish sat in the same file. This is strictly worse than a missed flag, because nothing in the output suggests anything went unexamined. The same hole existed for bunx, pnpx, and the two-word spellings pnpm dlx, yarn dlx, npm exec and bun x." +type: Issue +status: open +priority: 0 +tags[4]: "area:gates","area:release","area:supply-chain","type:defect" +created_at: "2026-08-28T12:38:36.013Z" +updated_at: "2026-08-28T20:48:39.553Z" +author: claude +acceptance_criteria: "npx, bunx, pnpx and the two-word pnpm dlx, yarn dlx, npm exec and bun x forms are all judged as publishes; a runner-prefixed publish that does carry the attestation flag still passes; the two-word runners are consumed only when the second word matches so a plain npm publish is unaffected; and reverting the skip-list change makes the new tests fail." +risk: critical +severity: critical +repro_steps: "Call auditPublishAttestation on a file holding an attested plain publish followed by npx npm publish --access public. Before the fix the result carries no failures, because the runner-prefixed publish is not recognised and the attested one satisfies the non-vacuity guard." +expected_result: "A publish is judged on what it does, not on how it is spelled, so a runner-prefixed publish is checked for the attestation flag like any other." +actual_result: "A runner-prefixed publish was not recognised as a publish, was never checked, and left the gate reporting clean." +component: scripts/verify-release-publish-attestation.ts executableIndex +customer_impact: "A release could publish an unattested artifact while every gate reported green, defeating the supply-chain guarantee the gate exists to provide." +comments[2]{created_at,author,text}: + "2026-08-28T12:52:01.213Z",claude,"A second bypass of the same class, raised by Greptile on PR 57 and confirmed: a shell string handed over through a combined short-option cluster was never inspected. POSIX shells accept bash -ec and bash -euc, which run the string exactly as bash -c does, but the executor resolver matched -c as a whole token only. Measured before the fix: bash -c is caught, while bash -ec, bash -euc and sh -ec all read as clean over an unattested publish, because the workflow's ordinary attested publish still satisfies the non-vacuity guard. The resolver now recognises -c inside a single-dash short-option cluster, and excludes long options deliberately so that --command is not misread as a cluster containing c. Reverting the change fails two of the thirty-two cases." + "2026-08-28T20:48:39.523Z",pi-agent,Verified pull request 57 before merge. Its required CI checks are green and review threads are resolved. The published gate and its package-runner regression coverage are present on the current head. +notes[1]{created_at,author,text}: + "2026-08-28T20:48:39.523Z",pi-agent,"Local verification passed: npm test, npm run release:check, npm run changelog:full, npm run changelog:check, and pinned pm health --strict-exit. npm run lint is unavailable because package.json has no lint script." +tests[1]{command,scope,timeout_seconds,note}: + "npm run release:check",project,300,full release gate for pull request 57 +docs[2]{path,scope,note}: + scripts/verify-release-publish-attestation.ts,project,executableIndex now skips package runners before choosing the executable + test/verify-release-publish-attestation.test.ts,project,regression cases for every runner spelling plus the attested-runner and two-word-runner mirrors +body: "" diff --git a/.agents/pm/issues/pm-github-i5b8.toon b/.agents/pm/issues/pm-github-i5b8.toon new file mode 100644 index 0000000..2ade243 --- /dev/null +++ b/.agents/pm/issues/pm-github-i5b8.toon @@ -0,0 +1,21 @@ +id: pm-github-i5b8 +title: A failed provenance publish silently falls back to an unattested one +description: "After three failed provenance publish attempts the release step called npm publish without --provenance and reported success. The only signal was a GitHub warning annotation. A transient registry failure therefore downgrades the package's supply-chain attestation permanently for that version, and consumers cannot tell an unattested publish caused by a 404 storm apart from one that never had provenance at all." +type: Issue +status: closed +priority: 1 +tags[3]: "area:release","area:supply-chain","type:defect" +created_at: "2026-08-28T06:37:21.751Z" +updated_at: "2026-08-28T12:43:19.731Z" +closed_at: "2026-08-28T06:37:26.185Z" +completed_at: "2026-08-28T06:37:26.185Z" +author: codex +acceptance_criteria: The release step has no publish path that omits --provenance; three provenance attempts are made; a version that landed despite a reported error is reconciled instead of republished; exhausting the attempts fails the job rather than publishing unattested; the workflow YAML parses and every bash run script passes bash -n. +comments[1]{created_at,author,text}: + "2026-08-28T06:37:21.751Z",codex,"Non-vacuity proof:\nTest A (restore fallback): exit 1 — FAIL - .github/workflows/release.yml: a publish invocation does not enable --provenance, so it would publish an unattested artifact: npm publish --access public --ignore-scripts\nTest B (--provenance=false): exit 1 — FAIL - .github/workflows/release.yml: a publish invocation does not enable --provenance, so it would publish an unattested artifact: npm publish --access public --provenance=false --ignore-scripts\nTest C (clean tree): exit 0 — ok - .github/workflows/release.yml: 1 publish invocation(s), each carrying --provenance\n\nGate table:\ntypecheck: 0, coverage: 92.57/82.89/91.75, verify:release-publish-attestation: 0" +files[1]{path,scope,note}: + .github/workflows/release.yml,project,publish step refuses to downgrade attestation and reconciles a late-landing version +tests[1]{command,scope,timeout_seconds}: + node scripts/verify-release-publish-attestation.ts,project,120 +close_reason: fixed +body: "The fallback was added while the registry was returning 404s, to get a release out. That trade is wrong for a supply-chain artifact: an unattested publish is not a degraded success, it is a different artifact. Failing the job leaves main holding the prepared version so the next run resumes the same release rather than inventing another one, which is exactly what the surrounding transaction was designed to do." diff --git a/.agents/pm/issues/pm-github-ypi5.toon b/.agents/pm/issues/pm-github-ypi5.toon new file mode 100644 index 0000000..4531887 --- /dev/null +++ b/.agents/pm/issues/pm-github-ypi5.toon @@ -0,0 +1,20 @@ +id: pm-github-ypi5 +title: The daily release cannot build a changelog once the tracker outgrows the default output budget +description: "The release job failed at the Generate changelog and release notes step with pm list-all --json answer was incomplete and was refused, count=5 of total=86. The repository pins pm-changelog through a caret range whose lockfile still resolved 2026.8.17, and that version reads the whole tracker with a plain whole-tracker list and does not pass the unbounded output-budget and output-limit controls that 2026.8.22 added. npx prefers the locally installed copy over the registry, so the job ran 2026.8.17 regardless of what npm served. With 89 items this repository crossed the default output budget, the read came back truncated, and pm-changelog correctly refused to build a changelog from a partial workspace rather than silently omitting entries. The refusal is right; the stale pin is the defect." +type: Issue +status: open +priority: 0 +tags[3]: "area:changelog","area:release","type:defect" +created_at: "2026-08-28T12:38:13.873Z" +updated_at: "2026-08-28T12:38:27.413Z" +author: pi-agent +acceptance_criteria: The lockfile resolves pm-changelog 2026.8.22 or newer; the exact argv the release workflow runs for generation and for check both exit zero against the full 89 item tracker; and the installed copy is proven by reading its package.json version rather than by the range in package.json. +risk: critical +severity: critical +expected_result: The release reads the entire tracker and generates a complete changelog no matter how many items the repository has accumulated. +actual_result: The changelog step exits non-zero for any repository whose tracker exceeds the default output budget. +affected_version: pm-changelog 2026.8.17 as locked before this change +component: package.json devDependency on pm-changelog +docs[1]{path,scope,note}: + package.json,project,pm-changelog devDependency raised to the version carrying the unbounded whole-tracker read +body: "" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d77718..252f32b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,5 +88,8 @@ jobs: - name: Verify generated changelog run: npm run changelog:check + - name: Verify every publish invocation is attested + run: npm run verify:release-publish-attestation + - name: Verify Bun can install the package graph run: bun install --no-save diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 117b8bf..b096ca9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -262,9 +262,9 @@ jobs: shell: bash run: | set -euo pipefail - npx pm-changelog --pm-root .agents/pm --mode replace --output CHANGELOG.md --all-release-tags --release-version-from-package --date-from-version --item-url-base https://github.com/unbraind/pm-github/blob/main/.agents/pm --respect-item-release --pm-bin ./node_modules/.bin/pm --github-step-summary - npx pm-changelog --pm-root .agents/pm --mode replace --output CHANGELOG.md --all-release-tags --release-version-from-package --date-from-version --item-url-base https://github.com/unbraind/pm-github/blob/main/.agents/pm --respect-item-release --pm-bin ./node_modules/.bin/pm --check - npx pm-changelog --pm-root .agents/pm --stdout --since-previous-tag --until-release-tag --release-version-from-package --date-from-version --item-url-base https://github.com/unbraind/pm-github/blob/main/.agents/pm --respect-item-release --pm-bin ./node_modules/.bin/pm > RELEASE_NOTES.md + npx pm-changelog --pm-root .agents/pm --mode replace --output CHANGELOG.md --all-release-tags --release-version-from-package --date-from-version --item-url-base https://github.com/unbraind/pm-github/blob/main/.agents/pm --respect-item-release --pm-bin ./node_modules/.bin/pm --pm-arg=--output-budget --pm-arg=unbounded --pm-arg=--output-limit --pm-arg=unbounded --github-step-summary + npx pm-changelog --pm-root .agents/pm --mode replace --output CHANGELOG.md --all-release-tags --release-version-from-package --date-from-version --item-url-base https://github.com/unbraind/pm-github/blob/main/.agents/pm --respect-item-release --pm-bin ./node_modules/.bin/pm --pm-arg=--output-budget --pm-arg=unbounded --pm-arg=--output-limit --pm-arg=unbounded --check + npx pm-changelog --pm-root .agents/pm --stdout --since-previous-tag --until-release-tag --release-version-from-package --date-from-version --item-url-base https://github.com/unbraind/pm-github/blob/main/.agents/pm --respect-item-release --pm-bin ./node_modules/.bin/pm --pm-arg=--output-budget --pm-arg=unbounded --pm-arg=--output-limit --pm-arg=unbounded > RELEASE_NOTES.md - name: Run release checks if: steps.decide.outputs.should_release == 'true' @@ -609,17 +609,67 @@ jobs: # already-published release (e.g. 2026.8.10, which landed on npm while # main was still at 2026.8.7) reconcile instead of failing with a 403 # when the workflow catches up and re-runs the transaction. - if npm view "${pkg_name}@${NPM_VERSION}" version --json >/dev/null 2>&1; then - echo "::notice::${pkg_name}@${NPM_VERSION} already published; skipping publish step." + # Reconciliation must check the ARTIFACT, not just the version string. + # `npm view @ version` proves only that something is + # published under that coordinate. It cannot distinguish the attested + # package this job just produced from an unattested one published + # earlier, or from another commit -- so a reconcile that accepts mere + # existence would tag and release the current SHA on the strength of + # an artifact nobody verified. That is the very substitution this + # workflow refuses to make on the publish path, so it must not make it + # on the recovery path either. + # + # Every publish this workflow performs carries `--provenance`, so a + # version of ours that landed necessarily has attestations. Their + # presence is therefore the discriminator: attested means "our publish + # got through", absent means "something else is sitting on this + # coordinate" and is refused rather than reconciled. + registry_version_is_attested() { + local attestations + attestations="$(npm view "${pkg_name}@${NPM_VERSION}" dist.attestations --json 2>/dev/null || true)" + [[ -n "${attestations}" && "${attestations}" != "null" && "${attestations}" != "{}" && "${attestations}" != "[]" ]] + } + registry_has_version() { + npm view "${pkg_name}@${NPM_VERSION}" version --json >/dev/null 2>&1 + } + # Answers one question and nothing else: is an attested copy of this + # exact version visible right now? It must never terminate the step + # itself -- attestation metadata can appear a moment after the version + # does, and that read can fail transiently, so an `exit` in here would + # turn a lag into a hard failure that skips the tag and leaves npm + # ahead of Git. The retry loop decides when to stop asking; the + # refusal below decides what an exhausted loop means. + reconciled_attested() { + registry_has_version && registry_version_is_attested + } + # Reached only once the loop has stopped asking. An occupied + # coordinate with no attestation is the case worth naming: this + # workflow only ever publishes with --provenance, so that artifact did + # not come from this job, and republishing cannot repair it because npm + # forbids overwriting a published version. It needs a human, and saying + # so is more useful than a green run over an artifact the release notes + # will misdescribe. + refuse_unattested_or_fail() { + if registry_has_version; then + echo "::error::${pkg_name}@${NPM_VERSION} exists on the registry WITHOUT a visible provenance attestation. This workflow only ever publishes with --provenance, so either that artifact did not come from this job, or its attestation never became visible. Refusing to tag and release around it; investigate before re-running." + else + echo "::error::Publish with provenance failed after ${max_attempts} attempts. Refusing to downgrade supply-chain attestations; retry the release transaction." + fi + exit 1 + } + + # Idempotence guard: if this exact version is already published AND + # attested, treat the publish as done. This is what lets an + # already-published release (e.g. 2026.8.10, which landed on npm while + # main was still at 2026.8.7) reconcile instead of failing with a 403 + # when the workflow catches up and re-runs the transaction. + if reconciled_attested; then + echo "::notice::${pkg_name}@${NPM_VERSION} already published and attested; skipping publish step." exit 0 fi publish_with_provenance() { npm publish --access public --provenance --ignore-scripts } - publish_without_provenance() { - echo "::warning::Falling back to publish WITHOUT --provenance after repeated 404 from npm registry." - npm publish --access public --ignore-scripts - } attempt=0 max_attempts=3 while (( attempt < max_attempts )); do @@ -628,24 +678,43 @@ jobs: echo "Published with provenance on attempt ${attempt}." exit 0 fi - if npm view "${pkg_name}@${NPM_VERSION}" version --json >/dev/null 2>&1; then - echo "::notice::Version landed despite reported error; treating as success." + if reconciled_attested; then + echo "::notice::Version landed attested despite the reported error; treating as success." exit 0 fi - echo "Publish attempt ${attempt}/${max_attempts} failed; sleeping 30s before retry..." - sleep 30 + if (( attempt < max_attempts )); then + echo "Publish attempt ${attempt}/${max_attempts} failed; sleeping 30s before retry..." + sleep 30 + fi done - echo "::warning::All ${max_attempts} provenance publish attempts failed; trying once without provenance." - if publish_without_provenance; then - echo "Published without provenance." - exit 0 - fi - if npm view "${pkg_name}@${NPM_VERSION}" version --json >/dev/null 2>&1; then - echo "::notice::Version landed during fallback; treating as success." - exit 0 - fi - echo "::error::Publish failed after retries and provenance fallback." - exit 1 + # npm can accept a publish and still report an error, and the registry + # needs a moment to propagate before `npm view` can see it. The retry + # loop gave that grace incidentally, through the sleep between + # attempts; the final attempt has no sleep after it, so a single + # immediate read here would race propagation and fail a release npm + # had already accepted -- skipping the tag and the GitHub release for a + # version that is on the registry, which is the "npm ahead of git" + # split the release ordering exists to prevent. Poll instead. + # + # 5 attempts, 30s apart. The bun verification step further down this + # file already retries the same registry on the same 30s schedule, + # because a version the registry has just accepted is not immediately + # visible; the npm read here needs the same grace for the same reason. + # A shorter window would fail a release that the very next step would + # then find. The cost is paid only on the path where npm has already + # reported an error, never on a successful publish. + reconcile_attempts=5 + for reconcile_attempt in $(seq 1 "${reconcile_attempts}"); do + if reconciled_attested; then + echo "::notice::Version landed attested after the final reported error; treating as success." + exit 0 + fi + if (( reconcile_attempt < reconcile_attempts )); then + echo "Not yet visible on the registry; re-reading in 30s (${reconcile_attempt}/${reconcile_attempts})..." + sleep 30 + fi + done + refuse_unattested_or_fail # Tag the exact merged/verified main commit AFTER a successful publish. # main is already advanced by the protected-PR merge above, so this step diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a9740e..450bee9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Fixed +- True round-trip GitHub sync: search provider, validate diagnostics, safe-by-default export, fix activation ([pm-github-9dqy](https://github.com/unbraind/pm-github/blob/main/.agents/pm/features/pm-github-9dqy.toon)) +- A failed provenance publish silently falls back to an unattested one ([pm-github-i5b8](https://github.com/unbraind/pm-github/blob/main/.agents/pm/issues/pm-github-i5b8.toon)) - Fix release publish ordering ahead of protected main push ([pm-github-v2kt](https://github.com/unbraind/pm-github/blob/main/.agents/pm/issues/pm-github-v2kt.toon)) - BREAKING: pm-github now requires pm CLI 2026.8.20 or newer; older hosts may fail installation or runtime validation ([pm-github-iswq](https://github.com/unbraind/pm-github/blob/main/.agents/pm/issues/pm-github-iswq.toon)) @@ -221,7 +223,6 @@ ### Fixed -- True round-trip GitHub sync: search provider, validate diagnostics, safe-by-default export, fix activation ([pm-github-9dqy](https://github.com/unbraind/pm-github/blob/main/.agents/pm/features/pm-github-9dqy.toon)) - True round-trip GitHub sync: search provider, validate diagnostics, safe-by-default export, fix activation ([pm-github-4elt](https://github.com/unbraind/pm-github/blob/main/.agents/pm/features/pm-github-4elt.toon)) - FIX: add 'preflight' to manifest capabilities (activation-breaking bug) ([pm-github-qndb](https://github.com/unbraind/pm-github/blob/main/.agents/pm/tasks/pm-github-qndb.toon)) diff --git a/package-lock.json b/package-lock.json index 7438237..0fb06bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "devDependencies": { "@types/node": "^26.1.1", "@unbrained/pm-cli": "2026.8.20", - "pm-changelog": "^2026.8.17", + "pm-changelog": "^2026.8.22", "pm-ops": "^2026.8.17", "typescript": "^7.0.2" }, @@ -1096,9 +1096,9 @@ } }, "node_modules/pm-changelog": { - "version": "2026.8.17", - "resolved": "https://registry.npmjs.org/pm-changelog/-/pm-changelog-2026.8.17.tgz", - "integrity": "sha512-wU52DpIWlSRhi0v3xtktgVF+oLte4Z+LWZ0H3VjbrhG+8XOEJrhevOu0YqMyiPQvNyt0SpDXy8OEhyxyaIcVEA==", + "version": "2026.8.22", + "resolved": "https://registry.npmjs.org/pm-changelog/-/pm-changelog-2026.8.22.tgz", + "integrity": "sha512-EazkSluZqJLOkKBjADT26IzA8E3teizm6lC3M5uuyKOH/cg09HL3gSdbLfxGqdSIMDsjXPRasw/giRQu/EKbzw==", "dev": true, "license": "MIT", "bin": { @@ -1108,7 +1108,7 @@ "node": ">=22.18.0" }, "peerDependencies": { - "@unbrained/pm-cli": ">=2026.8.16" + "@unbrained/pm-cli": ">=2026.8.20" } }, "node_modules/pm-ops": { diff --git a/package.json b/package.json index 6d92656..ecac281 100644 --- a/package.json +++ b/package.json @@ -29,9 +29,10 @@ "docstring": "node scripts/docstring-gate.ts", "audit:prod": "node --input-type=module -e \"import { spawnSync } from 'node:child_process'; import { devNull } from 'node:os'; const env = { ...process.env, npm_config_userconfig: devNull, NPM_CONFIG_USERCONFIG: devNull }; for (const key of Object.keys(env)) if (key.toLowerCase() === 'npm_config_allow_scripts') delete env[key]; const win = process.platform === 'win32'; const r = spawnSync(win ? 'npm.cmd' : 'npm', ['audit', '--omit=dev', '--ignore-scripts'], { stdio: 'inherit', env, shell: win }); process.exit(r.status ?? 1);\"", "pack:dry-run": "npm pack --dry-run", + "verify:release-publish-attestation": "node scripts/verify-release-publish-attestation.ts", "changelog:full": "pm-changelog --pm-root .agents/pm --pm-arg=--output-limit --pm-arg=unbounded --pm-arg=--output-budget --pm-arg=unbounded --mode replace --output CHANGELOG.md --all-release-tags --release-version-from-package --date-from-version --item-url-base https://github.com/unbraind/pm-github/blob/main/.agents/pm --respect-item-release", "changelog:check": "npm run changelog:full -- --check", - "release:check": "npm run typecheck && npm run build && npm run docstring && npm run privacy && npm run coverage && npm run audit:prod && npm run pack:dry-run && npm run changelog:check", + "release:check": "npm run typecheck && npm run build && npm run docstring && npm run privacy && npm run coverage && npm run audit:prod && npm run pack:dry-run && npm run changelog:check && npm run verify:release-publish-attestation", "prepublishOnly": "npm run release:check", "release:notes": "pm-changelog --pm-root .agents/pm --pm-arg=--output-limit --pm-arg=unbounded --pm-arg=--output-budget --pm-arg=unbounded --stdout --since-previous-tag --until-release-tag --release-version-from-package --date-from-version --item-url-base https://github.com/unbraind/pm-github/blob/main/.agents/pm --respect-item-release --pm-bin ./node_modules/.bin/pm --github-step-summary", "prepare": "node scripts/prepare-merge-driver.mjs", @@ -45,7 +46,7 @@ "devDependencies": { "@types/node": "^26.1.1", "@unbrained/pm-cli": "2026.8.20", - "pm-changelog": "^2026.8.17", + "pm-changelog": "^2026.8.22", "pm-ops": "^2026.8.17", "typescript": "^7.0.2" }, diff --git a/scripts/main-invocation.ts b/scripts/main-invocation.ts new file mode 100644 index 0000000..ad890b6 --- /dev/null +++ b/scripts/main-invocation.ts @@ -0,0 +1,51 @@ +/** + * Shared entry-point guard for the executable scripts in this package. + * + * All three shipped scripts (the coverage gate, the docstring gate and the + * merge-driver preparer) must behave identically when imported by their suites + * versus executed as `main`, so the comparison lives in exactly one measured + * module and can never drift between copies. + */ + +import { realpathSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +/** + * Whether this module's caller is the process entry point rather than a test + * import. + * + * Both sides are canonicalised through `realpathSync` before comparison. A + * launcher reaching a script through a symlink (an npm bin shim, a linked + * workspace) would otherwise compare unequal and skip the gate silently. + * + * Resolving only `argv[1]` would be enough under Node's defaults, where the + * ESM loader realpaths a module before recording `import.meta.url`. It is not + * enough under `--preserve-symlinks`/`--preserve-symlinks-main`, which leave + * `moduleUrl` holding the symlink while `realpathSync(entry)` resolves it. + * The two would then compare unequal on a direct invocation and a gate would + * exit 0 without scanning — the exact silent skip this function exists to + * prevent, reintroduced by a runtime flag. Canonicalising both sides adds a + * second `realpathSync` and removes the dependence on how Node was launched. + * + * An unresolvable `argv[1]` **propagates** rather than returning false. The two + * outcomes are not equally safe: returning false means a release check exits 0 + * having scanned nothing, which is a required gate reporting success without + * doing its job. Letting `realpathSync` throw turns that into a loud non-zero + * exit. The case requires `argv[1]` to stop resolving after Node has already + * loaded the script, so in practice it means the environment is broken, and a + * broken environment must not silently satisfy a gate. + * + * A genuinely different entry path still returns false, which is how a test + * importing a script declines to run its main wiring. + * + * @param argv - The process argv to inspect. + * @param moduleUrl - The `import.meta.url` of the module that might be main. + * @returns True when `argv[1]` and `moduleUrl` canonicalise to the same path, + * false when they canonicalise to different ones. + * @throws Whatever `realpathSync` throws when either path cannot be resolved. + */ +export function isMainInvocation(argv: readonly string[], moduleUrl: string): boolean { + const entry = argv[1]; + if (entry === undefined) return false; + return realpathSync(entry) === realpathSync(fileURLToPath(moduleUrl)); +} diff --git a/scripts/shell-command-scan.ts b/scripts/shell-command-scan.ts new file mode 100644 index 0000000..e450360 --- /dev/null +++ b/scripts/shell-command-scan.ts @@ -0,0 +1,626 @@ +/** + * Tokenises shell text into the commands it would actually run. + * + * A guard that decides "does any publish here omit `--provenance`" is only as + * good as its idea of what a command is. The previous scan answered that + * question with a regular expression: it blanked every quoted span so an + * advisory `echo "npm publish"` could not read as an invocation, then split the + * remainder on `&&`, `||`, `;` and a space-surrounded `|`. + * + * Both halves of that shortcut are wrong in the same direction -- they make the + * gate report a pass it has not earned: + * + * - Blanking quoted spans deletes the argument being audited. `npm publish + * "--provenance"` runs with an attestation but scans as one without, and the + * reverse case is worse: `eval "npm publish"` and `bash -c 'npm publish'` are + * real unattested publishes that vanish entirely, leaving a conventional + * attested sibling elsewhere in the file to carry the audit to green. + * - Splitting on three operators misses a backgrounding `&`, a pipe written + * without surrounding spaces (`true|npm publish`), and command substitution. + * + * So the text is tokenised the way a shell does it -- quotes resolved rather + * than erased, operators recognised as operators, `$(...)`, backticks, `eval` + * and `sh -c` payloads recursed into -- and each command records whether its + * words were quoted. Nothing downstream has to guess. + * + * This is deliberately not a shell. It does not expand variables, globs or + * arithmetic, and it does not track redirections. It exists to enumerate + * candidate command invocations for auditing, where missing one is a security + * failure and inventing one is merely noise. + * + * @packageDocumentation + */ + +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +/** One word of a command, after quote resolution. */ +export interface ShellToken { + /** The word's text with its quoting removed. */ + value: string; + /** True when any part of the word came from inside quotes. */ + quoted: boolean; + /** + * True when the word's FIRST character came from inside quotes. + * + * `quoted` alone cannot tell an assignment apart from a literal that merely + * looks like one. `NPM_CONFIG_REGISTRY="https://example"` is a real + * assignment whose value happens to be quoted, while `"FOO=bar"` is a single + * quoted word that the shell does not treat as an assignment at all. Both set + * `quoted`; only the second starts inside quotes. + */ + startsQuoted: boolean; +} + +/** One simple command: the words it would run, in order. */ +export type ShellCommand = ShellToken[]; + +/** + * Words that precede a command without being the command. + * + * `env FOO=bar npm publish` runs npm, not env, so a scan that reads the first + * word as the command name would classify it as an `env` invocation and let the + * publish through unaudited. + * + * The package runners (`npx`, `bunx`, `pnpx`) belong here for the same reason, + * and they bring their own options: `npx --yes npm publish` runs npm behind two + * words, not one. Option words following a prefix are therefore skipped too -- + * see `skipCommandPrefix`, which is where that rule is applied and bounded. + * + * Runners spelled as two words live in `TWO_WORD_PREFIXES` instead, because + * their head word is only a wrapper in combination with the word after it. + */ +const COMMAND_PREFIXES = new Set([ + "env", + "exec", + "nohup", + "command", + "builtin", + "sudo", + "doas", + "nice", + "ionice", + "time", + "stdbuf", + "setsid", + "xargs", + "npx", + "bunx", + "pnpx", + // Shell keywords introduce a command rather than being one. `if npm publish` + // runs npm; a scan that reads `if` as the program audits nothing. + "if", + "then", + "else", + "elif", + "while", + "until", + "do", + "!", + "{", + "(", +]); + +/** + * Wrappers spelled as two words, mapped to the second word that completes them. + * + * `pnpm dlx npm publish` runs npm, but `pnpm publish` runs pnpm's own publish + * and `pnpm install` runs no wrapper at all. Consuming the head word + * unconditionally would therefore re-point an unrelated `pnpm` command at its + * first argument, so the pair is only consumed when the second word matches. + */ +const TWO_WORD_PREFIXES = new Map([ + ["npm", new Set(["exec", "x"])], + ["pnpm", new Set(["dlx", "exec"])], + ["yarn", new Set(["dlx", "exec"])], + ["bun", new Set(["x", "run"])], +]); + +/** + * Reduce a program word to the name it runs. + * + * `/usr/local/bin/npm publish` runs npm, so a check against the whole word + * would miss it. `String.prototype.split` always yields at least one element, + * including for the empty string, so no fallback is needed or reachable here. + * + * @param word - The program word as written. + * @returns The final path segment. + */ +function basename(word: string): string { + const segments = word.split("/"); + return segments[segments.length - 1]!; +} + +/** Commands whose string argument is itself shell text to be scanned. */ +const SHELL_EVALUATORS = new Set(["eval", "bash", "sh", "dash", "zsh", "ksh"]); + +/** True when the character ends a word outside of quotes. */ +function isOperatorStart(character: string): boolean { + return character === ";" + || character === "&" + || character === "|" + || character === "\n" + || character === "(" + || character === ")" + || character === "{" + || character === "}"; +} + +/** + * Read a `$(...)` or backtick substitution and return its inner text. + * + * Nesting is counted so `$(echo $(npm publish))` yields the whole inner body + * rather than stopping at the first `)`; a truncated body would drop the + * invocation it contains. + * + * @param text - The full text being scanned. + * @param start - Index of the character that opens the substitution. + * @returns The inner text and the index just past the closing delimiter. + */ +function readSubstitution(text: string, start: number): { inner: string; end: number } { + if (text[start] === "`") { + const close = text.indexOf("`", start + 1); + if (close === -1) return { inner: text.slice(start + 1), end: text.length }; + return { inner: text.slice(start + 1, close), end: close + 1 }; + } + // A parenthesis inside quotes is a literal, not a delimiter. Counting it + // closes the substitution early and truncates the body, so + // `$(echo ")" && npm publish)` loses the publish entirely. + let depth = 1; + let index = start + 2; + let single = false; + let double = false; + while (index < text.length && depth > 0) { + const character = text[index]!; + if (character === "\\") index += 2; + else { + // Quote state is bounded to one line. A workflow's prose carries + // apostrophes -- "GitHub's", "workflow's" -- inside double-quoted + // messages, and letting an unbalanced one persist across lines makes + // every later parenthesis look quoted, so the substitution runs on and + // swallows unrelated commands. + if (character === "\n") { single = false; double = false; } + else if (character === "'" && !double) single = !single; + else if (character === '"' && !single) double = !double; + else if (!single && !double && character === "(") depth += 1; + else if (!single && !double && character === ")") depth -= 1; + if (depth === 0) break; + index += 1; + } + } + return { inner: text.slice(start + 2, index), end: index + 1 }; +} + +/** + * Split shell text into the simple commands it contains. + * + * Command substitutions are scanned as well as the command containing them, + * because `VERSION=$(npm publish)` runs a publish however unusual that is, and a + * gate that only looked at the outer assignment would miss it. + * + * `eval`, `bash -c` and their siblings receive the same treatment one level + * deeper: their string argument is re-tokenised, so a publish smuggled through + * an interpreter is enumerated alongside a plain one. Recursion is bounded -- + * shell text that nests evaluators more than a handful of levels deep is not + * something this repository writes, and an unbounded walk over hostile input is + * a denial of service rather than a stronger audit. + * + * @param text - Shell text, typically one file or one manifest script body. + * @param depth - Current evaluator recursion depth; callers pass nothing. + * @returns Every simple command found, outermost first. + */ +export function tokenizeCommands(text: string, depth = 0): ShellCommand[] { + if (depth > 8) return []; + const commands: ShellCommand[] = []; + const nested: string[] = []; + let command: ShellCommand = []; + let value = ""; + let quoted = false; + let startsQuoted = false; + let started = false; + + const endWord = (): void => { + if (!started) return; + command.push({ value, quoted, startsQuoted }); + value = ""; + quoted = false; + startsQuoted = false; + started = false; + }; + const endCommand = (): void => { + endWord(); + if (command.length > 0) commands.push(command); + command = []; + }; + + for (let index = 0; index < text.length; index += 1) { + const character = text[index]!; + if (character === "#" && !started) { + const newline = text.indexOf("\n", index); + index = newline === -1 ? text.length : newline; + endCommand(); + continue; + } + if (character === "\\") { + const next = text[index + 1]; + index += 1; + if (next === undefined) break; + if (next === "\n") continue; + value += next; + if (!started) startsQuoted = false; + started = true; + continue; + } + if (character === "'") { + const close = text.indexOf("'", index + 1); + const end = close === -1 ? text.length : close; + value += text.slice(index + 1, end); + quoted = true; + if (!started) startsQuoted = true; + started = true; + index = end; + continue; + } + if (character === '"') { + index += 1; + while (index < text.length && text[index] !== '"') { + const inner = text[index]!; + if (inner === "\\") { + const next = text[index + 1]; + if (next !== undefined) { + if (next !== "\n") value += next; + index += 2; + continue; + } + index += 1; + continue; + } + if (inner === "`" || (inner === "$" && text[index + 1] === "(")) { + const { inner: body, end } = readSubstitution(text, index); + nested.push(body); + index = end; + continue; + } + value += inner; + index += 1; + } + quoted = true; + if (!started) startsQuoted = true; + started = true; + continue; + } + if (character === "`" || (character === "$" && text[index + 1] === "(")) { + const { inner, end } = readSubstitution(text, index); + nested.push(inner); + index = end - 1; + if (!started) startsQuoted = false; + started = true; + continue; + } + if (character === " " || character === "\t" || character === "\r") { + endWord(); + continue; + } + if (isOperatorStart(character)) { + // `2>&1` is one redirection, not a command ended by a backgrounding `&`. + // The `&` belongs to the word only while that word is still an operator + // awaiting its target. + if (character === "&" && /^[0-9]*[<>]>?$/.test(value)) { + value += character; + started = true; + continue; + } + endCommand(); + continue; + } + value += character; + if (!started) startsQuoted = false; + started = true; + } + endCommand(); + + for (const body of nested) commands.push(...tokenizeCommands(body, depth + 1)); + for (const found of [...commands]) { + const name = commandName(found); + if (name === undefined || !SHELL_EVALUATORS.has(name)) continue; + // The shell joins an evaluator's words with a space and evaluates the + // result, so `eval "npm pub" "lish"` runs a publish that scanning each + // argument on its own never sees. + const payload = found.slice(1) + .filter((argument) => !argument.value.startsWith("-")) + .map((argument) => argument.value); + for (const body of new Set([...payload, payload.join(" ")])) { + commands.push(...tokenizeCommands(body, depth + 1)); + } + } + return commands; +} + +/** + * True when an unquoted word is a redirection operator rather than a command word. + * + * A redirection and its target are not part of the command the shell runs, so + * `> /dev/null npm publish` runs npm. A scan that reads words in order sees `>` + * as the program and audits nothing. The forms accepted here are the ones a + * workflow actually writes: the plain operators, a file-descriptor prefix + * (`2>`, `2>>`), and the duplicating forms (`>&`, `2>&1`, `&>`). + * + * @param token - One command word. + * @returns True when the word is a redirection operator. + */ +function isRedirection(token: ShellToken): boolean { + if (token.startsQuoted) return false; + return /^(?:[0-9]*(?:>>?|<>?)$/.test(token.value); +} + +/** + * Drop a command's redirections, so only the words it runs remain. + * + * An operator written apart from its target (`> file`) consumes the word after + * it; one written joined to it (`>file`, `2>&1`) consumes nothing further. + * + * @param command - One simple command's tokens. + * @returns The command without its redirections. + */ +function withoutRedirections(command: ShellCommand): ShellCommand { + const kept: ShellCommand = []; + for (let index = 0; index < command.length; index += 1) { + const token = command[index]!; + if (!isRedirection(token)) { + // A joined form such as `>file` or `2>&1` is one word and takes no target. + if (!token.startsQuoted && /^(?:[0-9]*>>?|[0-9]*<>?)[^\s]/.test(token.value)) continue; + kept.push(token); + continue; + } + // A bare operator takes the next word as its target. + if (!/&[0-9-]$/.test(token.value)) index += 1; + } + return kept; +} + +/** + * Walk past the words that precede the program a command runs. + * + * Three kinds of word are not the program: a leading `NAME=value` assignment, a + * wrapper listed in `COMMAND_PREFIXES`, and -- only once a wrapper has been + * seen -- that wrapper's own options. The last rule is what reaches the publish + * in `npx --yes npm publish`; it stays behind the wrapper condition so that a + * command whose own first word is an option is still reported as written rather + * than silently re-pointed at one of its arguments. + * + * An option's separate value (`sudo -u root npm publish`) is not skipped, + * because which options take a value differs per wrapper, and guessing wrong + * would move the reported program rather than merely widen the search. + * + * @param command - One simple command's tokens. + * @returns The index of the program word, or the command's length when there is none. + */ +function skipCommandPrefix(command: ShellCommand): number { + let index = 0; + let sawPrefix = false; + while (index < command.length) { + const token = command[index]!; + if (!token.startsQuoted && /^[A-Za-z_][A-Za-z0-9_]*=/.test(token.value)) { + index += 1; + continue; + } + const base = basename(token.value); + if (COMMAND_PREFIXES.has(base)) { + sawPrefix = true; + index += 1; + continue; + } + const second = command[index + 1]; + if (second !== undefined && TWO_WORD_PREFIXES.get(base)?.has(second.value) === true) { + sawPrefix = true; + index += 2; + continue; + } + if (sawPrefix && !token.startsQuoted && token.value.startsWith("-")) { + index += 1; + continue; + } + // A YAML key carries the command as its value: `run: npm publish` runs npm, + // and reading `run:` as the program audits nothing. Workflow files are + // scanned as raw text, so the key is a word like any other. Only a leading + // key is consumed, and only one, so an argument that merely ends in a colon + // is untouched. + // A YAML list marker precedes the key on the same line: `- run: npm publish`. + if (index === 0 && !token.startsQuoted && token.value === "-") { + sawPrefix = true; + index += 1; + continue; + } + if (index <= 1 && !token.startsQuoted && /^[A-Za-z_][A-Za-z0-9_-]*:$/.test(token.value)) { + sawPrefix = true; + index += 1; + continue; + } + return index; + } + return index; +} + +/** + * Name the program a command runs, or nothing when it runs none. + * + * Leading `NAME=value` assignments and wrapper words are skipped, and a path is + * reduced to its basename so `/usr/local/bin/npm publish` is recognised. The + * distinction this exists to draw is command *position*: `echo npm publish` + * prints three words and publishes nothing, while the previous scan searched + * the whole line for the word `npm` and counted it as an invocation. + * + * @param command - One simple command's tokens. + * @returns The program's basename, or undefined for an empty or assignment-only command. + */ +export function commandName(input: ShellCommand): string | undefined { + const command = withoutRedirections(input); + const token = command[skipCommandPrefix(command)]; + return token === undefined ? undefined : basename(token.value); +} + +/** + * Enumerate every reading of a command that could name a program. + * + * `commandName` answers "what does this command run" and answers it once. That + * is right for reporting and wrong for auditing, because a wrapper's options + * are not all known: `sudo -u root npm publish` stops at `root`, since `-u` + * takes a value and nothing here knows that. Enumerating the value-taking + * options of every wrapper would be a list that silently goes stale, and each + * omission is a publish that disappears from the audit. + * + * So once a wrapper has been consumed, every later word is also offered as a + * possible program, with the words after it as its arguments. An auditor asking + * "does any publish here lack an attestation" then cannot miss one behind a + * wrapper option it has never heard of. + * + * The cost is noise, never a miss: `sudo -u npm publish` -- a user actually + * named `npm` -- is offered as a publish that no shell would run. For a gate + * whose failure mode is an unattested release, a spurious finding an operator + * dismisses is the cheaper error. + * + * A command with no wrapper yields exactly one reading, so ordinary commands + * are unaffected. + * + * @param command - One simple command's tokens. + * @returns Each candidate reading, the command's own first. + */ +export function commandCandidates(input: ShellCommand): ShellCommand[] { + const command = withoutRedirections(input); + const start = skipCommandPrefix(command); + const candidates: ShellCommand[] = []; + if (start < command.length) candidates.push(command.slice(start)); + if (start === 0) return candidates; + for (let index = start + 1; index < command.length; index += 1) { + const token = command[index]!; + if (token.value.startsWith("-")) continue; + candidates.push(command.slice(index)); + } + return candidates; +} + +/** + * List a command's arguments -- everything after its program name. + * + * @param command - One simple command's tokens. + * @returns The argument tokens, in order. + */ +export function commandArguments(input: ShellCommand): ShellToken[] { + const command = withoutRedirections(input); + return command.slice(skipCommandPrefix(command) + 1); +} + +/** A tracked file's path and contents. */ +export interface SourceFile { + /** Repository-relative path. */ + file: string; + /** File contents. */ + text: string; +} + +/** + * Collapse shell and YAML line continuations so one logical command is one string. + * + * A backslash at end of line joins the next line; without this every multi-line + * invocation looks like a set of fragments, none of which carries both the + * version input and the date flag. + * + * @param text - Raw file contents. + * @returns The same text with continuations joined. + */ +export function joinContinuations(text: string): string { + return text.replace(/\\\r?\n\s*/g, " "); +} + +/** + * Index bash array assignments so a shared options array can be expanded. + * + * The release workflows declare `common=( ... )` once and pass `"${common[@]}"` + * to each invocation, precisely so the invocations cannot drift. A scan that + * reads only the invocation line therefore sees none of the shared flags. + * + * @param text - File contents with continuations already joined. + * @returns Array name mapped to the flag text it holds. + */ +export function bashArrays(text: string): Map { + const arrays = new Map(); + for (const match of text.matchAll(/(?:^|\s)([A-Za-z_][A-Za-z0-9_]*)=\(([\s\S]*?)\)/g)) { + arrays.set(match[1], match[2].replace(/\s+/g, " ").trim()); + } + return arrays; +} + +/** + * Index scalar assignments so a command held in a variable can be audited. + * + * `CMD="npm publish"` followed by `$CMD` runs a publish that no scan of the + * invocation line can see, because the invocation line contains no publish. The + * assignment is where the command actually is. + * + * Only literal single- or double-quoted values are indexed. An unquoted value + * cannot hold a space and so cannot hold a command, and a value built from + * other variables is not resolvable without evaluating the script, which this + * module deliberately does not do. + * + * @param text - File contents with continuations already joined. + * @returns Variable name mapped to the literal text it holds. + */ +export function shellScalars(text: string): Map { + const scalars = new Map(); + for (const match of text.matchAll(/(?:^|[\s;&|])([A-Za-z_][A-Za-z0-9_]*)=(?:"([^"\n]*)"|'([^'\n]*)')/g)) { + // The alternation guarantees exactly one of the two value groups matched, + // so there is no third case to fall back to. + const value = match[2] ?? match[3]!; + // Only a plain literal is inlined. A value carrying a substitution, a + // backtick, or a quote of its own changes how the line it lands in parses: + // inlining `pkg_name="$(node -p …)"` injects an unbalanced parenthesis into + // an unrelated command, and the scan then reports invocations that are not + // there while losing the one that is. That is a false verdict in both + // directions, which is worse than not resolving the variable at all. + if (/[$`"'()]/.test(value)) continue; + scalars.set(match[1]!, value); + } + return scalars; +} + +/** + * Expand `$name` and `${name}` references against the file's scalar assignments. + * + * An unknown name is left in place for the same reason an unknown array is: + * erasing it would turn "not understood" into "carries no flags", which reads + * as a pass. + * + * @param line - One logical command. + * @param scalars - Scalar assignments from the same file. + * @returns The command with known scalar references inlined. + */ +export function expandScalars(line: string, scalars: Map): string { + // One of the two alternatives always captures the name, so there is no + // nameless match to guard against. + return line.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (whole, braced?: string, bare?: string) => scalars.get(braced ?? bare!) ?? whole); +} + +/** + * Expand `"${name[@]}"` references against the file's array declarations. + * + * An unknown name is left untouched rather than erased: silently dropping it + * would turn "this scan does not understand the command" into "this command has + * no flags", which reads as a pass. + * + * @param line - One logical command. + * @param arrays - Array declarations from the same file. + * @returns The command with referenced array contents inlined. + */ +export function expandArrays(line: string, arrays: Map): string { + return line.replace(/"?\$\{([A-Za-z_][A-Za-z0-9_]*)\[@\]\}"?/g, (whole, name: string) => + arrays.get(name) ?? whole); +} + +/** The outcome of one verifier run. */ +export interface VerifierResult { + /** Reasons the run failed; empty means it passed. */ + failures: string[]; + /** Lines describing what was checked, for the operator. */ + notes: string[]; +} + diff --git a/scripts/verify-release-publish-attestation.ts b/scripts/verify-release-publish-attestation.ts new file mode 100644 index 0000000..e3dc6a0 --- /dev/null +++ b/scripts/verify-release-publish-attestation.ts @@ -0,0 +1,424 @@ +/** + * Proves this package has no publish path that omits `--provenance`. + * + * The release step used to fall back to `npm publish` without the flag after + * three failed provenance attempts, reporting success and leaving only a + * warning annotation. That makes a transient registry failure downgrade the + * published artifact's supply-chain attestation permanently for that version, + * and consumers cannot tell such a publish apart from one that never had + * provenance at all. An unattested publish is not a degraded success; it is a + * different artifact. + * + * A workflow edit is easy to make and easy to lose, so the contract is executed + * rather than assumed: every `npm publish` this repository can run is found and + * required to carry the flag. The analysis is separated from the I/O so the + * rules are driven by the suite against fixtures rather than only against this + * repository, which happens to satisfy them. + * + * @packageDocumentation + */ +import { execFileSync } from "node:child_process"; +import { closeSync, openSync, readFileSync, readSync } from "node:fs"; +import { resolve } from "node:path"; + +import { + bashArrays, + commandArguments, + commandCandidates, + commandName, + expandArrays, + expandScalars, + joinContinuations, + shellScalars, + type ShellCommand, + type SourceFile, + tokenizeCommands, + type VerifierResult, +} from "./shell-command-scan.ts"; +import { isMainInvocation } from "./main-invocation.ts"; + +/** The flag that attaches a build attestation to the published tarball. */ +export const ATTESTATION_FLAG = "--provenance"; + +/** One publish invocation found in a tracked file. */ +export interface PublishInvocation { + /** File the invocation was found in. */ + file: string; + /** The program the invocation runs, reduced to its basename. */ + program: string; + /** The invocation's tokens, quoting resolved. */ + command: ShellCommand; +} + +/** Publishers other than npm, which this repository has no attested path for. */ +export const FOREIGN_PUBLISHERS = new Set(["yarn", "pnpm", "bun"]); + +/** Repository subtrees whose contents are build output rather than a publish path. */ +const GENERATED_PREFIXES = ["dist/", "coverage/", "node_modules/", ".agents/pm/runtime/"]; + +/** Tracked paths that can execute a command, matched against the repository-relative path. */ +const EXECUTABLE_PATHS = [ + /^\.github\/workflows\/[^/]+\.ya?ml$/, + /(^|\/)package\.json$/, + /\.(sh|bash|zsh|ksh)$/, + /(^|\/)(Makefile|makefile|GNUmakefile)$/, + /\.mk$/, + /(^|\/)Dockerfile([.-][^/]*)?$/, + /(^|\/)docker-compose([.-][^/]*)?\.ya?ml$/, +]; + +/** + * Yield the command text held inside a package manifest. + * + * A manifest is JSON, so its script bodies are string values rather than lines + * of shell. Handing the raw file to a shell tokeniser would read the JSON + * punctuation as commands and the script bodies as quoted words. Parsing the + * manifest and returning the bodies restores them to the shape the scanner + * expects, which matters because a publish moved into an npm script is entirely + * real and would otherwise be invisible to this gate. + * + * A manifest that will not parse yields nothing rather than throwing, so a + * malformed sibling file cannot take the gate down; the manifest's own tooling + * reports that far better than a publish audit can. + * + * @param text - The manifest's contents. + * @returns One line per script body, newline joined. + */ +export function manifestCommandLines(text: string): string { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return ""; + } + if (typeof parsed !== "object" || parsed === null) return ""; + const scripts = (parsed as { scripts?: unknown }).scripts; + if (typeof scripts !== "object" || scripts === null) return ""; + // Each script is its own command list, so one cannot continue into the next. + // A body ending in a backslash would otherwise be joined to the following + // script by continuation collapsing, and a script beginning `--provenance` + // would lend its flag to the unattested publish that ended the script before + // it -- turning two commands into one attested-looking command. + return Object.values(scripts as Record) + .filter((value): value is string => typeof value === "string") + .map((value) => value.replace(/\\+$/, "")) + .join("\n"); +} + +/** Subcommands that run something else, so a later `publish` word is its argument. */ +// npm's runner subcommands, which take a script or package name rather than +// publishing. `workspace` is deliberately absent: it is not a subcommand at all +// -- npm selects a workspace with the `-w`/`--workspace` FLAG -- and listing it +// here only meant that a `publish` written after the word was never audited. +const RUNNER_SUBCOMMANDS = new Set(["run", "run-script", "exec", "explore", "x"]); + +/** + * Decide whether one command is a direct `npm publish`. + * + * `publish` does not have to follow `npm` immediately: npm accepts its + * configuration flags anywhere on the line, so `npm --access public publish` is + * a real publish that an adjacency test discards silently, leaving an attested + * sibling elsewhere in the file to carry the audit to a pass. + * + * Reading the first non-flag word as the subcommand does not work either, + * because npm has flags that take a separate value (`--access public`) and + * flags that do not (`--ignore-scripts`), and telling them apart needs npm's + * own option table. So the word is looked for anywhere in the arguments, and + * only a preceding runner subcommand rules it out -- `npm run publish` runs a + * package script whose body is scanned from the manifest, and requiring the + * flag on the runner would report a defect that is not there. + * + * The residual imprecision is `npm --tag publish ...`, a dist-tag named after + * the subcommand, which this reads as a publish. That direction is deliberate: + * a false positive is a report line to argue with, a false negative is an + * unattested artifact on the registry. + * + * The program is checked in command position by the caller, so `echo npm + * publish` and `notnpm publish` never reach here. + * + * @param command - One simple command's tokens. + * @returns True when the command publishes. + */ +export function isPublishCommand(command: ShellCommand): boolean { + for (const token of commandArguments(command)) { + if (RUNNER_SUBCOMMANDS.has(token.value)) return false; + if (token.value === "publish") return true; + } + return false; +} + +/** + * Decide whether one publish command actually enables the attestation. + * + * A substring test is not enough. `--provenance=false`, `--provenance false` and + * `--no-provenance` all contain the flag's spelling and all turn the + * attestation off, so a containment check accepts precisely the regression this + * gate exists to catch -- while reporting the file as attested. `--provenance-file` + * is a different flag entirely and must not be read as this one. + * + * Tokens are judged in order and the last one wins, which is how npm resolves a + * flag given more than once: `--provenance --no-provenance` publishes without an + * attestation, so this must answer false for it. + * + * Quoting is irrelevant to the shell and so is irrelevant here: `npm publish + * "--provenance"` is attested, and the scan this replaces read it as bare. + * + * @param command - One simple command's tokens. + * @returns True when the command publishes with an attestation. + */ +export function attestationEnabled(command: ShellCommand): boolean { + const args = commandArguments(command); + let enabled = false; + for (let index = 0; index < args.length; index += 1) { + const token = args[index]!.value; + if (token === `--no-${ATTESTATION_FLAG.slice(2)}`) { + enabled = false; + continue; + } + if (token === ATTESTATION_FLAG) { + const next = args[index + 1]?.value; + if (next === "true" || next === "false") { + enabled = next === "true"; + index += 1; + continue; + } + enabled = true; + continue; + } + if (token.startsWith(`${ATTESTATION_FLAG}=`)) { + enabled = token.slice(ATTESTATION_FLAG.length + 1) === "true"; + } + } + return enabled; +} + +/** + * Find every publish invocation in one file's contents. + * + * Continuations are joined and shared arrays expanded before tokenising, for + * the same reason the changelog-date scan does it: a multi-line invocation + * otherwise looks like fragments, none of which carries the flag. + * + * @param source - The file's path and contents. + * @returns The publish invocations found, in file order. + */ +export function publishInvocationsIn(source: SourceFile): PublishInvocation[] { + const raw = source.file.endsWith("package.json") ? manifestCommandLines(source.text) : source.text; + const text = joinContinuations(raw); + const arrays = bashArrays(text); + const scalars = shellScalars(text); + const expanded = text + .split("\n") + .map((line) => expandScalars(expandArrays(line, arrays), scalars)) + .join("\n"); + const found: PublishInvocation[] = []; + for (const command of tokenizeCommands(expanded)) { + // Every reading, not just the command's own: a wrapper option that takes a + // value (`sudo -u root npm publish`) moves the program past where naming it + // once would look. Missing a publish is a failed audit; offering one that no + // shell would run is noise an operator dismisses. + for (const candidate of commandCandidates(command)) { + const program = commandName(candidate); + if (program === undefined) continue; + if (program !== "npm" && !FOREIGN_PUBLISHERS.has(program)) continue; + if (!isPublishCommand(candidate)) continue; + // Not de-duplicated: two identical publish lines are two invocations, and + // collapsing them would report one of them as if the other did not exist. + found.push({ file: source.file, program, command: candidate }); + } + } + return found; +} + +/** + * Render an invocation back to a readable command for a report line. + * + * @param command - The invocation's tokens. + * @returns The command as a single space-separated string. + */ +export function renderCommand(command: ShellCommand): string { + return command.map((token) => token.value).join(" ").slice(0, 160); +} + +/** + * Audit every publish invocation across the given files. + * + * An absent invocation is a failure rather than a pass: a scan that finds + * nothing has either been pointed at the wrong files or outlived the workflow + * it guards, and both look identical to a clean result unless said out loud. + * + * A publisher other than npm fails outright rather than being checked for a + * flag. This repository's attested path is npm's `--provenance`; no equivalent + * is configured for yarn, pnpm or bun, so such an invocation is an unattested + * publish path regardless of the flags it carries, and guessing at another + * tool's spelling would be a gate that only looked strict. + * + * @param sources - The tracked files to scan. + * @returns Failures and per-file notes. + */ +export function auditPublishAttestation(sources: SourceFile[]): VerifierResult { + const invocations = sources.flatMap(publishInvocationsIn); + const failures: string[] = []; + const counted = new Map(); + for (const invocation of invocations) { + const tally = counted.get(invocation.file) ?? { total: 0, unflagged: 0 }; + tally.total += 1; + if (invocation.program !== "npm") { + tally.unflagged += 1; + failures.push( + `${invocation.file}: \`${invocation.program} publish\` is a publish path with no attested` + + ` equivalent configured in this repository: ${renderCommand(invocation.command)}`, + ); + } else if (!attestationEnabled(invocation.command)) { + tally.unflagged += 1; + failures.push( + `${invocation.file}: a publish invocation does not enable ${ATTESTATION_FLAG}, so it would` + + ` publish an unattested artifact: ${renderCommand(invocation.command)}`, + ); + } + counted.set(invocation.file, tally); + } + if (invocations.length === 0) { + failures.push("no npm publish invocation was found in any tracked file - the scan is looking in the wrong place"); + } + const notes: string[] = []; + for (const [file, tally] of counted) { + if (tally.unflagged > 0) continue; + notes.push(`ok - ${file}: ${tally.total} publish invocation(s), each carrying ${ATTESTATION_FLAG}`); + } + return { failures, notes }; +} + +/** + * Decide whether a tracked path can run a command. + * + * The previous enumeration named two paths -- `.github/workflows` and + * `package.json` -- which meant a publish added to any tracked script was never + * audited, and because the workflow's own attested publish satisfied the + * non-vacuity check the gate still reported that every invocation was attested. + * Auditing every shape that can execute closes that, and a shebang is honoured + * so an extensionless tracked script is not a blind spot either. + * + * Build output is excluded. `dist/` is generated from sources this scan already + * reads, it is regenerated and compared byte-for-byte on the release path, and + * including it would audit a bundled copy of a command rather than the command. + * + * @param path - Repository-relative path. + * @param firstLine - The file's first line, for shebang detection. + * @returns True when the file should be scanned. + */ +export function isExecutableSource(path: string, firstLine: string): boolean { + if (GENERATED_PREFIXES.some((prefix) => path.startsWith(prefix))) return false; + if (firstLine.startsWith("#!")) return true; + return EXECUTABLE_PATHS.some((pattern) => pattern.test(path)); +} + +/** + * Read the first two bytes of a file, or nothing when it cannot be read. + * + * Only a shebang is being looked for, so the whole file is never loaded -- + * `git ls-files` can name a large tracked asset, and this runs once per + * candidate. A tracked path that cannot be opened at all (a dangling symlink, + * a file removed from the working tree but still in the index) is not a + * publish path and must not take the gate down; it reads as empty. + * + * The handle is closed by an inner `finally` rather than one wrapping the + * catch, so there is no unreachable fall-through for the coverage gate to + * report as an untested branch. + * + * @param file - Absolute path to read. + * @returns The first two bytes as text, or an empty string. + */ +function firstBytes(file: string): string { + try { + const handle = openSync(file, "r"); + try { + const buffer = Buffer.alloc(2); + readSync(handle, buffer, 0, 2, 0); + return buffer.toString("utf8"); + } finally { + closeSync(handle); + } + } catch { + return ""; + } +} + +/** + * List the tracked files that can run a publish. + * + * Git is asked rather than the filesystem walked, so an untracked scratch copy + * of a workflow cannot satisfy or fail the gate. `-z` is used because a tracked + * path may legally contain a newline, and splitting such a listing on newlines + * invents two paths that do not exist and drops the one that does. + * + * @param root - Repository root. + * @returns Repository-relative paths of every tracked file that can execute. + */ +export function trackedPublishSources(root: string): string[] { + const listed = execFileSync("git", ["ls-files", "-z"], { + cwd: root, + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + }); + return listed + .split("\0") + .filter((path) => path.length > 0) + .filter((path) => isExecutableSource(path, firstBytes(resolve(root, path)))); +} + +/** + * Read the tracked sources and audit them. + * + * @param root - Repository root to verify. + * @returns Failures and notes for the whole repository. + */ +export function verify(root: string): VerifierResult { + const sources: SourceFile[] = trackedPublishSources(root).map((file) => ({ + file, + text: readFileSync(resolve(root, file), "utf8"), + })); + return auditPublishAttestation(sources); +} + +/** + * Print a result and set a failing exit code when it failed. + * + * @param result - The audit outcome. + * @param write - Sink for the report lines. + * @param exit - Called with the process exit code when there were failures. + */ +export function report( + result: VerifierResult, + write: (line: string) => void, + exit: (code: number) => void, +): void { + for (const note of result.notes) write(note); + for (const failure of result.failures) write(`FAIL - ${failure}`); + if (result.failures.length > 0) { + write(`verify-release-publish-attestation: ${result.failures.length} failure(s).`); + exit(1); + return; + } + write("verify-release-publish-attestation: every publish invocation is attested."); +} + +/** + * Verify and report, but only when this module is the process entry point. + * + * The guard is a function rather than a bare `if` at module scope so the suite + * can execute both answers. A bare `if` leaves its own body unreachable from any + * in-process test, which is how an entry point quietly stops running. + * + * @param argv - The process argv to judge. + * @param moduleUrl - This module's `import.meta.url`. + * @param root - Repository root to verify. + * @returns True when the verifier ran. + */ +export function runIfMain(argv: string[], moduleUrl: string, root: string): boolean { + if (!isMainInvocation(argv, moduleUrl)) return false; + report(verify(root), (line) => process.stdout.write(`${line}\n`), (code) => { process.exitCode = code; }); + return true; +} + +runIfMain(process.argv, import.meta.url, resolve(import.meta.dirname, "..")); diff --git a/test/shell-command-scan.test.ts b/test/shell-command-scan.test.ts new file mode 100644 index 0000000..c9fde1b --- /dev/null +++ b/test/shell-command-scan.test.ts @@ -0,0 +1,55 @@ +/** + * Tests for the shared shell-text scanner and the main-invocation guard. + * + * These live beside the modules rather than inside a gate's suite because both + * release gates depend on them while not every package carries both gates. + * When these assertions belonged to the changelog-date suite, propagating the + * scanner to a package without that gate silently dropped a branch from + * coverage -- which is the failure this file exists to prevent. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { resolve } from "node:path"; + +import { bashArrays, expandArrays, joinContinuations } from "../scripts/shell-command-scan.ts"; +import { isMainInvocation } from "../scripts/main-invocation.ts"; + +test("an unknown array reference is left in place rather than erased", () => { + // Erasing it would turn "this scan does not understand the command" into + // "this command carries no flags", which reads as a pass. + assert.equal(expandArrays('cmd "${missing[@]}"', new Map()), 'cmd "${missing[@]}"'); + assert.equal(expandArrays('cmd "${known[@]}"', new Map([["known", "--a --b"]])), "cmd --a --b"); +}); + +test("bashArrays collapses whitespace so a multi-line declaration is one flag string", () => { + assert.equal(bashArrays("common=(\n --a\n --b\n)").get("common"), "--a --b"); +}); + +test("the main-invocation guard answers both ways", () => { + // Name the module under test, not a gate: not every package carries the same + // gates, and a path that resolves nowhere makes realpathSync throw rather + // than answer. + const self = fileURLToPath(import.meta.resolve("../scripts/main-invocation.ts")); + const url = import.meta.resolve("../scripts/main-invocation.ts"); + assert.equal(isMainInvocation(["node", self], url), true); + assert.equal(isMainInvocation(["node", fileURLToPath(import.meta.url)], url), false); + assert.equal(isMainInvocation(["node"], url), false); +}); +test("a backslash continuation makes one logical command out of several lines", () => { + assert.equal( + joinContinuations("npm publish \\\n --provenance \\\n --access public\n"), + // The joiner replaces the backslash-newline with a single space and leaves + // the continuation line's own indentation, which the tokeniser then eats. + "npm publish --provenance --access public\n", + ); + // A backslash that does not end a line is an ordinary character. + assert.equal(joinContinuations("printf 'a\\tb'\n"), "printf 'a\\tb'\n"); +}); + +test("an array reference is replaced by the declaration's contents, quoted or bare", () => { + const arrays = bashArrays('common=( --access public --provenance )\n'); + assert.equal(expandArrays('npm publish "${common[@]}"', arrays), "npm publish --access public --provenance"); + assert.equal(expandArrays("npm publish ${common[@]}", arrays), "npm publish --access public --provenance"); +}); diff --git a/test/verify-release-publish-attestation.test.ts b/test/verify-release-publish-attestation.test.ts new file mode 100644 index 0000000..b6ad38d --- /dev/null +++ b/test/verify-release-publish-attestation.test.ts @@ -0,0 +1,777 @@ +/** + * Executes the publish-attestation verifier's rules against fixtures. + * + * The verifier's own repository satisfies its rules, so running it here would + * only prove that today's tree is fine. What these cases prove is that each + * rule still FAILS on the defect it exists to catch -- an unattested publish + * reachable from the release workflow -- and that the two shapes which make a + * naive substring scan useless are handled: a publish spelled across a line + * continuation, and a prose mention of the command inside a quoted string. + */ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { tmpdir } from "node:os"; + +import { + ATTESTATION_FLAG, + attestationEnabled, + auditPublishAttestation, + isExecutableSource, + isPublishCommand, + manifestCommandLines, + publishInvocationsIn, + report, + renderCommand, + runIfMain, + trackedPublishSources, + verify, +} from "../scripts/verify-release-publish-attestation.ts"; +import { commandArguments, commandCandidates, commandName, expandScalars, shellScalars, tokenizeCommands } from "../scripts/shell-command-scan.ts"; + +/** Tokenises one command and returns it, asserting the text held exactly one. */ +function onlyCommand(text: string): ReturnType[number] { + const commands = tokenizeCommands(text); + assert.equal(commands.length, 1, `expected one command in ${JSON.stringify(text)}`); + return commands[0]!; +} + +const ATTESTED = `npm publish --access public ${ATTESTATION_FLAG} --ignore-scripts`; +const UNATTESTED = "npm publish --access public --ignore-scripts"; + +/** Builds a throwaway git repository holding the given tracked files. */ +function trackedFixture(files: Record): string { + const root = mkdtempSync(join(tmpdir(), "attestation-")); + execFileSync("git", ["init", "-q", "."], { cwd: root }); + for (const [path, text] of Object.entries(files)) { + mkdirSync(join(root, path, ".."), { recursive: true }); + writeFileSync(join(root, path), text); + } + execFileSync("git", ["add", "-A"], { cwd: root }); + return root; +} + +test("an unattested publish fails, naming the command that would run", () => { + const result = auditPublishAttestation([{ file: "release.yml", text: ` ${UNATTESTED}` }]); + assert.equal(result.failures.length, 1); + assert.match(result.failures[0]!, /does not enable --provenance/); + assert.match(result.failures[0]!, /npm publish --access public --ignore-scripts/); +}); + +test("an attested publish passes and is reported by file", () => { + const result = auditPublishAttestation([{ file: "release.yml", text: ` ${ATTESTED}` }]); + assert.deepEqual(result.failures, []); + assert.deepEqual(result.notes, [`ok - release.yml: 1 publish invocation(s), each carrying ${ATTESTATION_FLAG}`]); +}); + +test("a file holding both an attested and an unattested publish fails, so one cannot cover for the other", () => { + const result = auditPublishAttestation([ + { file: "release.yml", text: ` ${ATTESTED}\n ${UNATTESTED}` }, + ]); + assert.equal(result.failures.length, 1); + assert.deepEqual(result.notes, [], "a file with an unattested publish must not also be reported as ok"); +}); + +test("two publishes chained on one line are judged separately", () => { + // Judging the line as a whole would let the flag on the first call satisfy + // the second, which is exactly the shape a line-oriented scan misses. + const result = auditPublishAttestation([ + { file: "release.yml", text: ` ${ATTESTED} && ${UNATTESTED}` }, + ]); + assert.equal(result.failures.length, 1); +}); + +test("a publish spelled across a line continuation is still seen with its flag", () => { + const result = auditPublishAttestation([ + { file: "release.yml", text: " npm publish --access public \\\n --provenance --ignore-scripts" }, + ]); + assert.deepEqual(result.failures, []); +}); + +test("a shared bash array holding the flag is expanded rather than read as an absent flag", () => { + const result = auditPublishAttestation([ + { file: "release.yml", text: ` flags=( --access public ${ATTESTATION_FLAG} )\n npm publish "\${flags[@]}"` }, + ]); + assert.deepEqual(result.failures, []); +}); + +test("a prose mention of the command inside quotes is not treated as an invocation", () => { + // This repository's own workflow echoes advice naming the command. Reading + // that echo as a publish makes the gate report a defect that is not there, + // and a gate that cries wolf gets weakened until it reports nothing. + const result = auditPublishAttestation([ + { file: "release.yml", text: ` echo "The trusted publisher must have 'npm publish' selected."` }, + ]); + assert.deepEqual(result.failures, ["no npm publish invocation was found in any tracked file - the scan is looking in the wrong place"]); +}); + +test("a commented-out publish is not treated as an invocation", () => { + const result = auditPublishAttestation([ + { file: "release.yml", text: ` # ${UNATTESTED}\n ${ATTESTED}` }, + ]); + assert.deepEqual(result.failures, []); +}); + +test("a trailing unquoted comment cannot supply the flag the command lacks", () => { + const result = auditPublishAttestation([ + { file: "release.yml", text: ` ${UNATTESTED} # ${ATTESTATION_FLAG}` }, + ]); + assert.equal(result.failures.length, 1); +}); + +test("a disabled attestation is not an attestation, in every spelling npm accepts", () => { + // Greptile P2: a containment check accepts `--provenance=false`, which is + // precisely the regression this gate exists to catch, and reports the file + // as attested while doing it. + for (const disabled of ["--provenance=false", "--no-provenance", "--provenance --no-provenance", "--provenance=0"]) { + assert.equal(attestationEnabled(onlyCommand(`npm publish --access public ${disabled}`)), false, disabled); + assert.equal( + auditPublishAttestation([{ file: "release.yml", text: ` npm publish --access public ${disabled}` }]).failures.length, + 1, + disabled, + ); + } + for (const enabled of ["--provenance", "--provenance=true", "--no-provenance --provenance"]) { + assert.equal(attestationEnabled(onlyCommand(`npm publish --access public ${enabled}`)), true, enabled); + } +}); + +test("a flag that merely starts with the attestation spelling does not enable it", () => { + assert.equal(attestationEnabled(onlyCommand("npm publish --provenance-file x")), false); +}); + +test("a publish hidden in an npm script is found, because a manifest is JSON and its scripts are quoted", () => { + // CodeRabbit: quoted spans are erased before a command is judged, which is + // what stops the workflow's advisory echo reading as an invocation. Applied + // to a manifest that erases the script bodies themselves, so a publish moved + // into an npm script would be invisible while being entirely real. + const manifest = JSON.stringify({ scripts: { release: UNATTESTED, build: "tsc" } }); + const result = auditPublishAttestation([{ file: "package.json", text: manifest }]); + assert.equal(result.failures.length, 1, "an unattested publish in a script must fail"); + assert.match(result.failures[0]!, /does not enable --provenance/); + const attested = JSON.stringify({ scripts: { release: ATTESTED } }); + assert.deepEqual(auditPublishAttestation([{ file: "package.json", text: attested }]).failures, []); +}); + +test("manifestCommandLines survives a manifest that is malformed, empty, or has no scripts", () => { + // A malformed sibling manifest must not take the gate down; its own tooling + // reports that far better than a publish audit can. + assert.equal(manifestCommandLines("{ not json"), ""); + assert.equal(manifestCommandLines("null"), ""); + assert.equal(manifestCommandLines("[]"), ""); + assert.equal(manifestCommandLines("{}"), ""); + assert.equal(manifestCommandLines(JSON.stringify({ scripts: null })), ""); + assert.equal(manifestCommandLines(JSON.stringify({ scripts: "not-an-object" })), ""); + assert.equal(manifestCommandLines(JSON.stringify({ scripts: { a: "x", b: 3, c: "y" } })), "x\ny"); +}); + +test("a publish with configuration flags before the subcommand is still a publish", () => { + // Greptile: npm accepts its flags anywhere on the line, so requiring `publish` + // to follow `npm` immediately discards a real unattested publish silently -- + // and an attested sibling elsewhere in the file then carries the audit to a + // pass. + const spread = "npm --access public publish --ignore-scripts"; + assert.equal(isPublishCommand(onlyCommand(spread)), true); + const result = auditPublishAttestation([ + { file: "release.yml", text: ` ${ATTESTED}\n ${spread}` }, + ]); + assert.equal(result.failures.length, 1, "the unattested sibling must be counted and failed"); +}); + +test("npm run publish is a script runner, not a publish", () => { + // The script's own body is scanned from the manifest, so requiring the flag + // on the runner would report a defect that is not there. + assert.equal(isPublishCommand(onlyCommand("npm run publish")), false); + assert.equal(isPublishCommand(onlyCommand("npm run-script publish")), false); + assert.equal(isPublishCommand(onlyCommand("npm publish")), true); + assert.equal(isPublishCommand(onlyCommand("npm ci")), false); + assert.equal(isPublishCommand(onlyCommand("npm exec publish")), false, "exec runs a binary, it does not publish"); + assert.equal(isPublishCommand(onlyCommand("npm --access public publish")), true, "a flag value is not the subcommand"); + assert.equal(isPublishCommand(onlyCommand("npm --ignore-scripts publish")), true); +}); + +test("finding no publish at all fails, because an empty scan and a clean tree look identical", () => { + const result = auditPublishAttestation([{ file: "release.yml", text: " npm ci\n" }]); + assert.deepEqual(result.failures, ["no npm publish invocation was found in any tracked file - the scan is looking in the wrong place"]); +}); + +test("only a command in command position is a publish, whatever else names npm", () => { + // CodeRabbit: searching a whole line for the word `npm` classified an + // announcement as an invocation and then failed it for lacking a flag no + // announcement could carry. What decides the question is command POSITION. + for (const mention of ["echo notnpm publish", "echo npm publish", "printf npm publish", "notnpm publish", "xnpm publish --access public"]) { + assert.deepEqual( + publishInvocationsIn({ file: "release.yml", text: ` ${mention}\n` }), + [], + mention, + ); + } + // The same words in command position, with a wrapper and a full path, are. + for (const real of ["npm publish --provenance", "/usr/local/bin/npm publish --provenance", "env CI=1 npm publish --provenance", "NPM_CONFIG_LOGLEVEL=silly npm publish --provenance"]) { + assert.equal(publishInvocationsIn({ file: "release.yml", text: ` ${real}\n` }).length, 1, real); + } +}); + +test("quoting a flag does not hide it, because the shell strips quotes before npm sees them", () => { + // CodeRabbit/Greptile: the scan blanked quoted spans, so an attested publish + // written with a quoted flag read as unattested -- and, far worse, a publish + // written inside a quoted string vanished from the audit entirely. + for (const quoted of [ + `npm publish --access public "${ATTESTATION_FLAG}"`, + `npm publish --access public '${ATTESTATION_FLAG}'`, + `npm publish --access public --provenance"" `, + `npm publish "--access" public ${ATTESTATION_FLAG}`, + ]) { + assert.deepEqual(auditPublishAttestation([{ file: "release.yml", text: ` ${quoted}` }]).failures, [], quoted); + } +}); + +test("an unattested publish smuggled through an interpreter or a substitution is still found", () => { + // Greptile P1 and CodeRabbit: `eval`, `bash -c` and `$(...)` payloads are + // shell text. The previous scan blanked them as quoted spans, so each of + // these published without an attestation while the workflow's own attested + // publish carried the audit to green. + for (const smuggled of [ + `eval "${UNATTESTED}"`, + `eval '${UNATTESTED}'`, + `bash -c "${UNATTESTED}"`, + `sh -c '${UNATTESTED}'`, + `output=$(${UNATTESTED})`, + "output=`npm publish --access public`", + `echo hi && eval "${UNATTESTED}"`, + ]) { + const failures = auditPublishAttestation([ + { file: "release.yml", text: ` ${ATTESTED}\n ${smuggled}` }, + ]).failures; + assert.equal(failures.length, 1, `${smuggled} -> ${JSON.stringify(failures)}`); + } +}); + +test("every shell separator ends a command, so a flagged publish cannot cover an unflagged neighbour", () => { + // The previous split knew `&&`, `||`, `;` and a space-surrounded `|` only, so + // a backgrounding `&` and a compact pipe fused two commands into one line + // that the flagged half then made pass. + for (const separator of ["&&", "||", ";", " | ", "|", "&", "\n"]) { + const text = ` ${ATTESTED} ${separator} ${UNATTESTED}`; + assert.equal( + auditPublishAttestation([{ file: "release.yml", text }]).failures.length, + 1, + `separator ${JSON.stringify(separator)}`, + ); + } +}); + +test("a publisher other than npm is refused rather than searched for a flag it has no equivalent of", () => { + for (const publisher of ["yarn", "pnpm", "bun"]) { + const result = auditPublishAttestation([ + { file: "release.yml", text: ` ${ATTESTED}\n ${publisher} publish --access public` }, + ]); + assert.equal(result.failures.length, 1, publisher); + assert.match(result.failures[0]!, new RegExp(`\\\`${publisher} publish\\\``)); + } +}); + +test("npm accepts a boolean value as a separate word, and so must this", () => { + // CodeRabbit: npm's option parser takes `--provenance false`. Reading only + // `--provenance` there reports an attestation the publish does not carry. + assert.equal(attestationEnabled(onlyCommand("npm publish --provenance false")), false); + assert.equal(attestationEnabled(onlyCommand("npm publish --provenance true")), true); + assert.equal(attestationEnabled(onlyCommand("npm publish --provenance --access public")), true, "a following flag is not a value"); + assert.equal(attestationEnabled(onlyCommand("npm publish --provenance false --provenance")), true, "the last spelling wins"); +}); + +test("tokenizeCommands resolves quoting, comments and escapes the way a shell does", () => { + assert.deepEqual(onlyCommand(`a "b c" d`).map((token) => token.value), ["a", "b c", "d"]); + assert.deepEqual(onlyCommand("a 'b c'").map((token) => token.value), ["a", "b c"]); + assert.deepEqual(onlyCommand("a\\ b").map((token) => token.value), ["a b"], "an escaped space joins one word"); + assert.deepEqual(onlyCommand('x "a\\"b"').map((token) => token.value), ["x", 'a"b'], "an escaped quote stays in the word"); + assert.deepEqual(tokenizeCommands("# only a comment"), []); + assert.deepEqual(onlyCommand("npm ci # trailing comment").map((token) => token.value), ["npm", "ci"]); + assert.deepEqual(tokenizeCommands("a\\"), [[{ value: "a", quoted: false, startsQuoted: false }]], "a trailing backslash does not read past the end"); + assert.deepEqual(tokenizeCommands("echo 'unterminated").map((c) => c.map((t) => t.value)), [["echo", "unterminated"]]); + assert.equal(onlyCommand('cmd "unterminated')[1]!.quoted, true); + assert.deepEqual(commandArguments(onlyCommand("env A=1 npm publish")).map((token) => token.value), ["publish"]); + assert.equal(commandName([]), undefined); + assert.equal(commandName(onlyCommand("A=1 B=2")), undefined, "assignments alone run no command"); + assert.equal(commandName(onlyCommand("'npm' publish")), "npm", "a quoted program name still runs it"); + // startsQuoted, not quoted, is what separates an assignment from a literal + // that merely looks like one: the shell assigns for the first and not the + // second, and only the second begins inside quotes. + assert.equal(onlyCommand('A="b c" npm')[0]!.startsQuoted, false, "a quoted VALUE still starts unquoted"); + assert.equal(onlyCommand('"A=b" npm')[0]!.startsQuoted, true, "a wholly quoted word starts quoted"); + assert.equal(onlyCommand("'A=b' npm")[0]!.startsQuoted, true); + assert.equal(onlyCommand("\\A=b npm")[0]!.startsQuoted, false, "an escape is not a quote"); + assert.equal(commandName(onlyCommand('NPM_CONFIG_REGISTRY="https://r.example" npm publish')), "npm"); + assert.equal(commandName(onlyCommand('"A=b" publish')), "A=b", "a quoted literal is the program, not an assignment"); +}); + +test("every reading of a wrapper-led command is offered, so an unknown option value cannot hide a program", () => { + // commandName answers once and is right to; an auditor cannot afford that, + // because `-u` takes a value and nothing here enumerates which options do. + const values = (command: ReturnType) => + commandCandidates(command).map((candidate) => commandName(candidate)); + assert.deepEqual(values(onlyCommand("sudo -u root npm publish")), ["root", "npm", "publish"]); + assert.deepEqual(values(onlyCommand("nice -n 10 npm publish")), ["10", "npm", "publish"]); + // No wrapper means exactly one reading, so ordinary commands are untouched. + assert.deepEqual(values(onlyCommand("npm publish --provenance")), ["npm"]); + assert.deepEqual(values(onlyCommand("echo npm publish")), ["echo"]); + // A command that is nothing but a wrapper offers no reading at all. + assert.deepEqual(commandCandidates(onlyCommand("sudo")), []); + assert.deepEqual(commandCandidates([]), []); + // A reading that is only assignments names no program, and is skipped rather + // than audited as one. + // A trailing reading that is only assignments names no program, so it is + // skipped rather than audited as one. + assert.deepEqual(values(onlyCommand("sudo -u root npm publish A=1")), ["root", "npm", "publish", undefined]); + assert.deepEqual( + publishInvocationsIn({ file: "release.yml", text: " sudo -u root npm publish --provenance A=1\n" }).length, + 1, + ); +}); + +test("two identical publish lines are two findings, not one", () => { + // Collapsing them would report one invocation as if the other did not exist, + // and an operator reading "1 unattested publish" would fix half the file. + const result = auditPublishAttestation([ + { file: "release.yml", text: " npm publish\n npm publish\n" }, + ]); + assert.equal(result.failures.length, 2); +}); + +test("a substitution inside double quotes is scanned, because the shell runs it before the quotes matter", () => { + // `"$(npm publish)"` looks like one quoted word and is a real invocation. + // Treating the quoting as decisive is exactly how the previous scan lost it. + for (const smuggled of [ + `message="$(${UNATTESTED})"`, + "message=\"`npm publish --access public`\"", + `message="prefix $(${UNATTESTED}) suffix"`, + ]) { + const failures = auditPublishAttestation([ + { file: "release.yml", text: ` ${ATTESTED}\n ${smuggled}` }, + ]).failures; + assert.equal(failures.length, 1, `${smuggled} -> ${JSON.stringify(failures)}`); + } +}); + +test("unterminated and nested substitutions terminate instead of reading past the end", () => { + // A substitution's OUTPUT is not knowable here, so it contributes an empty + // word to the command that contained it while its body is scanned as + // commands in its own right. What matters is that neither shape loops or + // swallows the rest of the file. + const words = (text: string): string[][] => tokenizeCommands(text).map((command) => command.map((token) => token.value)); + assert.deepEqual(words('cmd "abc\\'), [["cmd", "abc"]], "a trailing backslash inside quotes stops at the end"); + assert.deepEqual(words('cmd "a\\\nb"'), [["cmd", "ab"]], "an escaped newline inside quotes continues the word"); + assert.deepEqual(words("cmd a\\\nb"), [["cmd", "ab"]], "and outside quotes too"); + assert.deepEqual(words("cmd $("), [["cmd", ""]], "an unterminated substitution yields an empty word and no command"); + assert.deepEqual(words("cmd `unterminated"), [["cmd", ""], ["unterminated"]], "an unterminated backtick still scans its body"); + assert.deepEqual(words("a $(echo $(npm publish)) b"), [["a", "", "b"], ["echo", ""], ["npm", "publish"]], "nesting is counted, so the inner command survives"); + assert.deepEqual(words("a $(echo \\) x) b"), [["a", "", "b"], ["echo", ")", "x"]], "an escaped paren does not close the substitution"); +}); + +test("a tracked path that cannot be opened is skipped rather than taking the gate down", () => { + const root = trackedFixture({ + ".github/workflows/release.yml": ` ${ATTESTED}`, + }); + try { + symlinkSync("nowhere-at-all", join(root, "dangling")); + execFileSync("git", ["add", "dangling"], { cwd: root }); + assert.ok(!trackedPublishSources(root).includes("dangling"), "an unreadable tracked file is not a publish source"); + assert.deepEqual(verify(root).failures, [], "and it does not fail the gate either"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("evaluator recursion is bounded, so hostile nesting cannot hang the gate", () => { + let text = UNATTESTED; + // Escape backslashes before quotes. Escaping only the quote leaves a literal + // backslash in the payload able to consume the escape that follows it, so the + // nesting this test builds would not be the nesting it asserts on. + for (let depth = 0; depth < 12; depth += 1) { + text = `eval "${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; + } + assert.deepEqual(tokenizeCommands(text, 9), [], "past the bound the walk stops rather than recursing"); + assert.ok(tokenizeCommands(`eval "${UNATTESTED}"`).length > 1, "within the bound the payload is still scanned"); +}); + +test("renderCommand joins the resolved tokens and caps the length of a report line", () => { + assert.equal(renderCommand(onlyCommand(`npm publish "--access" public`)), "npm publish --access public"); + assert.equal(renderCommand(onlyCommand(`npm publish ${"x".repeat(400)}`)).length, 160); +}); + +test("trackedPublishSources asks git, so an untracked workflow copy cannot satisfy the gate", () => { + const root = trackedFixture({ + ".github/workflows/release.yml": ` ${ATTESTED}`, + "package.json": "{}", + }); + try { + writeFileSync(join(root, ".github/workflows/scratch.yml"), ` ${UNATTESTED}`); + assert.deepEqual(trackedPublishSources(root).sort(), [".github/workflows/release.yml", "package.json"]); + assert.deepEqual(verify(root).failures, [], "the untracked scratch copy must not be judged"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("a publish in any tracked executable is audited, not only workflows and the manifest", () => { + // Greptile P1: the enumeration named `.github/workflows` and `package.json`, + // so a publish added to a tracked script was never read -- and because the + // workflow's own attested publish satisfied the non-vacuity check, the gate + // reported that every invocation was attested. + const root = trackedFixture({ + ".github/workflows/release.yml": ` ${ATTESTED}`, + "package.json": "{}", + "scripts/ship.sh": `#!/usr/bin/env bash\n${UNATTESTED}\n`, + }); + try { + assert.ok(trackedPublishSources(root).includes("scripts/ship.sh")); + const failures = verify(root).failures; + assert.equal(failures.length, 1, JSON.stringify(failures)); + assert.match(failures[0]!, /scripts\/ship\.sh/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("an extensionless tracked script is audited when its shebang says it executes", () => { + const root = trackedFixture({ + ".github/workflows/release.yml": ` ${ATTESTED}`, + "tools/release": `#!/bin/sh\n${UNATTESTED}\n`, + "docs/notes": `${UNATTESTED}\n`, + }); + try { + const sources = trackedPublishSources(root); + assert.ok(sources.includes("tools/release"), "a shebang marks an executable source"); + assert.ok(!sources.includes("docs/notes"), "prose without a shebang is not a publish path"); + assert.equal(verify(root).failures.length, 1); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("committed build output is not audited, because it is generated from sources already read", () => { + const root = trackedFixture({ + ".github/workflows/release.yml": ` ${ATTESTED}`, + "dist/bundle.sh": `#!/bin/sh\n${UNATTESTED}\n`, + }); + try { + assert.deepEqual(trackedPublishSources(root), [".github/workflows/release.yml"]); + assert.deepEqual(verify(root).failures, []); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("isExecutableSource recognises the shapes that can run a command", () => { + for (const path of [".github/workflows/ci.yml", ".github/workflows/ci.yaml", "package.json", "web/package.json", "x.sh", "Makefile", "build/rules.mk", "Dockerfile", "Dockerfile.ci", "docker-compose.yml", "docker-compose.prod.yaml"]) { + assert.equal(isExecutableSource(path, ""), true, path); + } + for (const path of ["README.md", "src/index.ts", ".github/dependabot.yml", "package.json.bak"]) { + assert.equal(isExecutableSource(path, ""), false, path); + } + assert.equal(isExecutableSource("tools/release", "#!/bin/sh"), true, "a shebang overrides the shape"); + assert.equal(isExecutableSource("dist/bundle.sh", "#!/bin/sh"), false, "build output is excluded first"); + assert.equal(isExecutableSource("coverage/x.sh", ""), false); +}); + +test("verify reads the tracked files and fails on an unattested one", () => { + const root = trackedFixture({ ".github/workflows/release.yml": ` ${UNATTESTED}`, "package.json": "{}" }); + try { + assert.equal(verify(root).failures.length, 1); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("report prints notes then failures and asks for a failing exit code", () => { + const lines: string[] = []; + const codes: number[] = []; + report({ failures: ["bad"], notes: ["fine"] }, (line) => lines.push(line), (code) => codes.push(code)); + assert.deepEqual(lines, ["fine", "FAIL - bad", "verify-release-publish-attestation: 1 failure(s)."]); + assert.deepEqual(codes, [1]); +}); + +test("report on a clean result says so and asks for no exit code", () => { + const lines: string[] = []; + const codes: number[] = []; + report({ failures: [], notes: [] }, (line) => lines.push(line), (code) => codes.push(code)); + assert.deepEqual(lines, ["verify-release-publish-attestation: every publish invocation is attested."]); + assert.deepEqual(codes, []); +}); + +test("runIfMain runs only as the entry point, and reports when it does", () => { + const root = trackedFixture({ ".github/workflows/release.yml": ` ${ATTESTED}`, "package.json": "{}" }); + const previous = process.exitCode; + try { + // isMainInvocation canonicalises both sides, so a non-entry argument must + // name a file that exists; a missing path is a different failure entirely. + assert.equal(runIfMain(["node", "scripts/main-invocation.ts"], pathToFileURL(resolve("scripts/verify-release-publish-attestation.ts")).href, root), false); + assert.equal( + runIfMain( + ["node", "scripts/verify-release-publish-attestation.ts"], + pathToFileURL(resolve("scripts/verify-release-publish-attestation.ts")).href, + root, + ), + true, + ); + assert.equal(process.exitCode, previous, "an attested tree must not set a failing exit code"); + const failing = trackedFixture({ ".github/workflows/release.yml": ` ${UNATTESTED}`, "package.json": "{}" }); + try { + runIfMain( + ["node", "scripts/verify-release-publish-attestation.ts"], + pathToFileURL(resolve("scripts/verify-release-publish-attestation.ts")).href, + failing, + ); + assert.equal(process.exitCode, 1, "an unattested tree must set a failing exit code"); + } finally { + rmSync(failing, { recursive: true, force: true }); + } + } finally { + process.exitCode = previous; + rmSync(root, { recursive: true, force: true }); + } +}); + +test("a package runner is a wrapper, so the publish behind its own options is still audited", () => { + // Greptile raised the wrapper class generally: a publish reached through an + // interpreter or a runner escapes a scan that reads only the first word. The + // runners differ from `env` and `sudo` in that they carry their own options + // before the program, so skipping the wrapper word alone is not enough. + for (const wrapped of [ + "npx npm publish --provenance", + "npx --yes npm publish --provenance", + "bunx --bun npm publish --provenance", + "pnpx -y npm publish --provenance", + ]) { + assert.equal( + publishInvocationsIn({ file: "release.yml", text: ` ${wrapped}\n` }).length, + 1, + wrapped, + ); + } + // The same shape without the flag must fail, or the pass above proves nothing. + assert.equal( + auditPublishAttestation([{ file: "release.yml", text: " npx --yes npm publish\n" }]) + .failures.length, + 1, + ); +}); + +test("a runner spelled as two words is consumed only when its second word completes it", () => { + for (const wrapped of ["pnpm dlx npm publish --provenance", "yarn exec npm publish --provenance", "bun x npm publish --provenance"]) { + assert.equal( + publishInvocationsIn({ file: "release.yml", text: ` ${wrapped}\n` }).length, + 1, + wrapped, + ); + } + // Consuming the head word unconditionally would re-point an unrelated command + // at its first argument, so a non-matching second word leaves it alone. + assert.equal(commandName(onlyCommand("pnpm install npm publish")), "pnpm"); + assert.equal(commandName(onlyCommand("pnpm")), "pnpm"); + assert.equal(commandName(onlyCommand("bun run build")), "build"); + // And the unflagged two-word form must still fail. + assert.equal( + auditPublishAttestation([{ file: "release.yml", text: " pnpm dlx npm publish\n" }]) + .failures.length, + 1, + ); +}); + +test("an option in first position names the command it is written on, not one of its arguments", () => { + // Skipping option words is bounded to wrappers on purpose. Were it + // unconditional, a command whose own first word is an option would be + // re-pointed at an argument, and `--flag npm publish` would read as a publish + // that nothing in the tree actually runs. + assert.equal(commandName(onlyCommand("--yes npm publish")), "--yes"); + assert.deepEqual( + commandArguments(onlyCommand("--yes npm publish")).map((token) => token.value), + ["npm", "publish"], + ); + // A wrapper with nothing after it names no program rather than throwing. + assert.equal(commandName(onlyCommand("npx")), undefined); + assert.deepEqual(commandArguments(onlyCommand("npx")), []); + // A quoted option after a wrapper is a literal argument, not the wrapper's flag. + assert.equal(commandName(onlyCommand(`npx "--yes"`)), "--yes"); +}); + +test("a redirection and its target are not command words", () => { + // Greptile: `> /dev/null npm publish` runs npm, but a scan reading words in + // order sees `>` as the program and audits nothing. + const cases = [ + "> /dev/null npm publish", + ">/dev/null npm publish", + "2>/dev/null npm publish", + "2> /dev/null npm publish", + "&> /dev/null npm publish", + "npm publish > /dev/null", + "npm publish 2>&1", + ]; + for (const text of cases) { + assert.equal(commandName(onlyCommand(text)), "npm", text); + } + // The publish is still audited beside an attested sibling, which is the shape + // that made this a bypass rather than a curiosity. + const withSibling = { + file: "release.yml", + text: " npm publish --provenance\n > /dev/null npm publish\n", + }; + assert.equal(auditPublishAttestation([withSibling]).failures.length, 1); +}); + +test("a shell keyword introduces a command rather than being one", () => { + for (const text of ["if npm publish", "while npm publish", "until npm publish", "! npm publish"]) { + assert.equal(commandName(onlyCommand(text)), "npm", text); + } + // `npm exec` is a runner like `pnpm dlx`, with or without the `--` separator. + assert.equal(commandName(onlyCommand("npm exec -- npm publish")), "npm"); + assert.equal( + auditPublishAttestation([{ + file: "release.yml", + text: " npm publish --provenance\n if npm publish; then echo ok; fi\n", + }]).failures.length, + 1, + ); +}); + +test("a command held in a scalar is expanded, so the assignment is where the publish is found", () => { + const scalars = shellScalars('CMD="npm publish"\nOTHER=\'npm publish --provenance\'\nBARE=npm\n'); + assert.equal(scalars.get("CMD"), "npm publish"); + assert.equal(scalars.get("OTHER"), "npm publish --provenance"); + assert.equal(scalars.get("BARE"), undefined, "an unquoted value cannot hold a command"); + assert.equal(expandScalars("$CMD", scalars), "npm publish"); + assert.equal(expandScalars("${CMD}", scalars), "npm publish"); + assert.equal(expandScalars("$UNKNOWN", scalars), "$UNKNOWN", "an unknown name is left in place, not erased"); + assert.equal( + auditPublishAttestation([{ + file: "release.yml", + text: ' npm publish --provenance\n CMD="npm publish"\n $CMD\n', + }]).failures.length, + 1, + ); +}); + +test("a workflow key carries the command as its value, and is not the command", () => { + // Workflow files are scanned as raw text, so a YAML key is a word like any + // other: `run: npm publish` read `run:` as the program and audited nothing. + assert.equal(commandName(onlyCommand("run: npm publish")), "npm"); + assert.equal(commandName(onlyCommand("- run: npm publish")), "npm"); + // Only a LEADING key is consumed, so an argument that ends in a colon is not. + assert.equal(commandName(onlyCommand("echo label:")), "echo"); + assert.deepEqual( + commandArguments(onlyCommand("echo label: value")).map((token) => token.value), + ["label:", "value"], + ); + assert.equal( + auditPublishAttestation([{ + file: "release.yml", + text: " npm publish --provenance\n - run: npm publish\n", + }]).failures.length, + 1, + ); +}); + +test("a quoted parenthesis inside a substitution is a literal, not its delimiter", () => { + // Counting it closed the substitution early and truncated the body, so the + // publish after it was never scanned at all. + const result = auditPublishAttestation([{ + file: "release.yml", + text: ' npm publish --provenance\n x=$(echo ")" && npm publish)\n', + }]); + assert.equal(result.failures.length, 1); +}); + +test("one package script cannot continue into the next", () => { + // A body ending in a backslash was joined to the following script, so a + // script beginning `--provenance` lent its flag to the unattested publish + // that ended the script before it. + const manifest = JSON.stringify({ scripts: { a: "npm publish \\", b: "--provenance echo done" } }); + assert.equal(manifestCommandLines(manifest), "npm publish \n--provenance echo done"); + assert.equal(auditPublishAttestation([{ file: "package.json", text: manifest }]).failures.length, 1); +}); + +test("npm selects a workspace with a flag, so a word after it does not excuse a publish", () => { + // `workspace` was listed as a runner subcommand, which meant a `publish` + // written after it was never audited. npm has no such subcommand. + assert.equal( + auditPublishAttestation([{ + file: "release.yml", + text: " npm publish --provenance\n npm workspace pkg publish\n", + }]).failures.length, + 1, + ); + // A real runner subcommand still short-circuits: `npm run publish` runs a + // script named publish and publishes nothing. + assert.deepEqual( + publishInvocationsIn({ file: "release.yml", text: " npm run publish\n" }), + [], + ); +}); + +test("a substitution tracks both quote kinds and an escape while finding its close", () => { + // Each arm of the quote tracking has to be exercised or a later edit can + // remove one without the suite noticing. + const single = auditPublishAttestation([{ + file: "release.yml", + text: " npm publish --provenance\n x=$(echo ')' && npm publish)\n", + }]); + assert.equal(single.failures.length, 1, "a single-quoted paren is a literal"); + const escaped = auditPublishAttestation([{ + file: "release.yml", + text: " npm publish --provenance\n x=$(echo \\) && npm publish)\n", + }]); + assert.equal(escaped.failures.length, 1, "an escaped paren is a literal"); + // A double quote inside single quotes is literal, and vice versa. + assert.deepEqual( + tokenizeCommands(`x=$(echo '"' && npm publish --provenance)`).some( + (command) => commandName(command) === "npm", + ), + true, + ); +}); + +test("a scalar carrying a substitution or a quote of its own is never inlined", () => { + // This is a regression test for a defect this gate introduced in itself. + // Inlining every quoted assignment put values like `x="$(node -p …)"` into + // unrelated commands, which injected an unbalanced parenthesis, and the scan + // then reported a publish that was not there while losing the one that was -- + // a false verdict in both directions. Every package's release gate failed. + const scalars = shellScalars([ + 'CMD="npm publish"', + 'SUBST="$(node -p 1)"', + 'TICK="`date`"', + 'QUOTED="he said \'hi\'"', + 'PAREN="a (b)"', + ].join("\n")); + assert.equal(scalars.get("CMD"), "npm publish", "a plain literal is still resolved"); + for (const name of ["SUBST", "TICK", "QUOTED", "PAREN"]) { + assert.equal(scalars.get(name), undefined, `${name} must not be inlined`); + } + // The shape that actually broke: an attested publish elsewhere in the file + // must still be found, and no phantom invented. + const text = [ + ' pkg_name="$(node -p "require(\'./package.json\').name")"', + " # `npm publish` - mentioned in a comment", + " npm publish --access public --provenance --ignore-scripts", + ].join("\n"); + const found = publishInvocationsIn({ file: "release.yml", text }).map((i) => renderCommand(i.command)); + assert.deepEqual(found, ["npm publish --access public --provenance --ignore-scripts"]); +}); + +test("a substitution's quote state does not leak across its lines", () => { + // Workflow prose carries apostrophes inside double-quoted messages. If an + // unbalanced one persisted past the newline, every later parenthesis would + // look quoted and the substitution would run on past its real close, + // swallowing unrelated commands into it. + const text = [ + " x=$(echo \"GitHub's endpoint\"", + " npm publish)", + " npm publish --provenance", + ].join("\n"); + const found = publishInvocationsIn({ file: "release.yml", text }).map((i) => renderCommand(i.command)); + assert.ok(found.includes("npm publish"), "the publish inside the substitution is still found"); + assert.ok(found.includes("npm publish --provenance"), "and the one after it is not swallowed"); +});