diff --git a/.agents/pm/history/pm-github-release-merge-wait.jsonl b/.agents/pm/history/pm-github-release-merge-wait.jsonl new file mode 100644 index 00000000..085261cb --- /dev/null +++ b/.agents/pm/history/pm-github-release-merge-wait.jsonl @@ -0,0 +1,16 @@ +{"hash_algorithm":"sha256","ts":"2026-10-23T15:47:55.215Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"81dec87e440efc4f024215d0","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-github-release-merge-wait"},{"op":"add","path":"/metadata/title","value":"Derive release merge wait from CI duration and refresh a safe main advance once"},{"op":"add","path":"/metadata/description","value":"Daily Release PR #171 exceeded the fixed 30-minute wait while required Node 22 and 26 tests were still running. Measure recent successful main CI job durations, report the bounded wait derivation, and permit one protected release-branch update when intervening commits preserve release metadata."},{"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":["changelog-unreleased","ci","release"]},{"op":"add","path":"/metadata/created_at","value":"2026-10-23T15:47:55.215Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-10-23T15:47:55.215Z"},{"op":"add","path":"/metadata/author","value":"codex-agent"},{"op":"add","path":"/metadata/acceptance_criteria","value":"Workflow consumes tested decision helpers; wait floor is 60 minutes with a cap below the job timeout; one safe refresh restarts checks; release metadata drift, conflicts and repeated main advances abort; npm and Bun release gates pass."}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"1b4c41fef29cfb79894ead07597baa564c5d696238eb5c8ac196eaa0268b69c2","item_hash_version":3,"message":"","context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"09e085ab65f6d8dac4690af67b12ef1c4686bc8072db89a29df7bcd85704c69c"} +{"hash_algorithm":"sha256","ts":"2026-10-23T15:47:56.486Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"81dec87e440efc4f024215d0","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-23T15:47:56.486Z"},{"op":"add","path":"/metadata/assignee","value":"codex-agent"},{"op":"add","path":"/metadata/claim_principal","value":"codex-agent"}],"before_hash":"1b4c41fef29cfb79894ead07597baa564c5d696238eb5c8ac196eaa0268b69c2","after_hash":"7eed53f6a49cd90e40f5a5169eddd4ee8dc76d83c882ddefde0bb0d396172944","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"6176214a83f497daef563e8050affbfbfb5419466e4c76ff008772019c23e49c"} +{"hash_algorithm":"sha256","ts":"2026-10-23T15:47:57.779Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"81dec87e440efc4f024215d0","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-23T15:47:57.779Z"},{"op":"replace","path":"/metadata/status","value":"in_progress"}],"before_hash":"7eed53f6a49cd90e40f5a5169eddd4ee8dc76d83c882ddefde0bb0d396172944","after_hash":"5ae66ee61f88ad0e861b888ff82ca96507949de4daf46921080b0d5ce8f92f58","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"295e7d590ea9ad6151b434c212ee4ad7df4942883a2a0eb8f530a1ddf0aac336"} +{"hash_algorithm":"sha256","ts":"2026-10-23T15:47:59.125Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"81dec87e440efc4f024215d0","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-23T15:47:59.125Z"},{"op":"add","path":"/metadata/comments","value":[{"created_at":"2026-10-23T15:47:59.125Z","author":"codex-agent","text":"Plan: use 20 successful main CI runs and the successful test (22)/(26) job p95 plus max(15 minutes, 25 percent), bounded to 60-120 minutes under an explicit 360-minute job timeout. Inspect every intervening commit for package version or release-artifact changes before one leased branch refresh; restart the wait once. Use the workflow shell and real temporary git repositories for regression and revert proof."}]}],"before_hash":"5ae66ee61f88ad0e861b888ff82ca96507949de4daf46921080b0d5ce8f92f58","after_hash":"a7c75037f7a82206d8d39a6358698a0e451fd844214b80f112458b8270bd75e2","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"954885eb780a7b72b9f9722fb28ce447e335af03db3a6906ba1bcb76fa6fffa3"} +{"hash_algorithm":"sha256","ts":"2026-10-23T15:54:19.255Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"81dec87e440efc4f024215d0","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"files_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-23T15:54:19.255Z"},{"op":"add","path":"/metadata/files","value":[{"path":".github/workflows/release.yml","scope":"project","note":"Measured wait and one guarded release-base refresh"}]}],"before_hash":"a7c75037f7a82206d8d39a6358698a0e451fd844214b80f112458b8270bd75e2","after_hash":"8d20edb8146acf71b587016c9b867034b821bf66ef73cf1020730d13639cb225","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"86b932bceaeb60780915ece1fdacb36b3276e4c7d838a3c0c603b9949825c511"} +{"hash_algorithm":"sha256","ts":"2026-10-23T15:54:20.566Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"81dec87e440efc4f024215d0","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"files_add","patch":[{"op":"add","path":"/metadata/files/1","value":{"path":"scripts/release-merge-policy.ts","scope":"project","note":"CI timing and per-commit metadata policy"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-23T15:54:20.566Z"}],"before_hash":"8d20edb8146acf71b587016c9b867034b821bf66ef73cf1020730d13639cb225","after_hash":"133e4a72029c08ab9a563f49456080d017d7278f6960215c61b02bc40065e1f5","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"8e672c77d33cccb7fb59421d263aee114ac689cbb02e768746630e4a506620c4"} +{"hash_algorithm":"sha256","ts":"2026-10-23T15:54:21.885Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"81dec87e440efc4f024215d0","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"files_add","patch":[{"op":"add","path":"/metadata/files/2","value":{"path":"test/release-merge-policy.test.ts","scope":"project","note":"Real git fixtures and complete workflow-shell regressions"}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-23T15:54:21.885Z"}],"before_hash":"133e4a72029c08ab9a563f49456080d017d7278f6960215c61b02bc40065e1f5","after_hash":"8628c57f996dce0a658dcb945aaf0b187722d1a5abe0ba93fd7ce90d9aa0e4f3","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"ab6c5bff55e976a72d959885e6c34239de39875816669c5de0634a4ed8a701e1"} +{"hash_algorithm":"sha256","ts":"2026-10-23T15:54:23.298Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"81dec87e440efc4f024215d0","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"docs_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-23T15:54:23.298Z"},{"op":"add","path":"/metadata/docs","value":[{"path":"README.md","scope":"project","note":"Daily Release wait derivation and safe refresh limits"}]}],"before_hash":"8628c57f996dce0a658dcb945aaf0b187722d1a5abe0ba93fd7ce90d9aa0e4f3","after_hash":"763f2aa0d9132406017cee621a91c5e44941079ec36616f9865a22abb08a5d34","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"24004df508fe8161e10e48f7e8469b6fceda113ced760e10884f134234c245b6"} +{"hash_algorithm":"sha256","ts":"2026-10-23T15:54:24.907Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"81dec87e440efc4f024215d0","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"tests_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-23T15:54:24.907Z"},{"op":"add","path":"/metadata/tests","value":[{"command":"node --test test/release-merge-policy.test.ts test/daily-release-plan.test.ts test/release-approval.test.ts test/release-workflow.test.ts","scope":"project","timeout_seconds":180,"provenance":{"author":"codex-agent","created_at":"2026-10-23T15:54:24.865Z","source_kind":"local_mutation","source_ref":"fix/release-merge-wait-fits-ci"}}]}],"before_hash":"763f2aa0d9132406017cee621a91c5e44941079ec36616f9865a22abb08a5d34","after_hash":"a653d05cf119ee17fdd923c0f8548a673becc5aefa5fd4a7c4b32f2963a64b27","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"435589998e604ac2dd4048199e62439ac3b0bdf8fe3914487d1a0cc39f761704"} +{"hash_algorithm":"sha256","ts":"2026-10-23T15:54:59.527Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"81dec87e440efc4f024215d0","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/1","value":{"created_at":"2026-10-23T15:54:59.525Z","author":"codex-agent","text":"Live read-only execution of the workflow measurement step returned 40 successful Node 22/26 job samples from 20 main push CI runs: p95 1859 seconds plus 900 seconds margin, yielding the 3600-second floor. Focused regressions passed 24/24 and helper coverage was 100/100/100/100. Behavioral revert proof: fixed 30-minute wait failed 2 tests; bypassed metadata guard failed 8; restored abort-on-main-advance failed 3; omitted deadline restart failed 1. All mutants were restored. Rebase preserves a linear protected PR and each updated push uses the previously prepared head as its lease."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-23T15:54:59.527Z"}],"before_hash":"a653d05cf119ee17fdd923c0f8548a673becc5aefa5fd4a7c4b32f2963a64b27","after_hash":"fe7e374eefa9f90b8b49aa2dfb5ed76de644b7ea3995d9f81f2f7be13aa3daf6","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"24bbbf2d111480d16bb4432d55979240a085c6ad983b7d3863fca7137085c8af"} +{"hash_algorithm":"sha256","ts":"2026-10-23T15:56:03.826Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"81dec87e440efc4f024215d0","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/2","value":{"created_at":"2026-10-23T15:56:03.826Z","author":"codex-agent","text":"The linked pm test runner exposed an inherited tracker-selection environment in the new no-git fixture. The fixture now passes its own explicit tracker path to pm init, so it cannot select or mutate an enclosing tracker. The 60 release workflow tests now pass through pm test, including genuine package/lock dependency updates alongside the prepared version bump."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-23T15:56:03.826Z"}],"before_hash":"fe7e374eefa9f90b8b49aa2dfb5ed76de644b7ea3995d9f81f2f7be13aa3daf6","after_hash":"4c83cd8a9daf9409d64fd9c690ee472616266601c43436f716aa46accf269761","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"f37ceb852b1920a1a69a2e1d672177a7e450089d4f912afb21c53db2a62beadb"} +{"hash_algorithm":"sha256","ts":"2026-10-23T16:32:19.971Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"81dec87e440efc4f024215d0","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/3","value":{"created_at":"2026-10-23T16:32:19.971Z","author":"codex-agent","text":"The complete npm release gate passed with isolated HOME and git configuration: 1045 tests, zero failures or skips, exact 100/100/100/100 coverage over 19 authored modules, 188 documented declarations, zero source duplication, approved identities and no privacy findings, production audit, package dry run, changelog check and publish-attestation check. Strict tracked-project health also passed with isolated HOME. The required Bun release gate is running against the byte-identical source candidate in an independent validation worktree so build and coverage receipts cannot collide. Native Bun already passed all 24 new regressions."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-23T16:32:19.971Z"}],"before_hash":"4c83cd8a9daf9409d64fd9c690ee472616266601c43436f716aa46accf269761","after_hash":"6c3489ae98cddd15fb8e26cb2c82b262416e855cbcbf8b33af5f0d03042c5930","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"8ef501549d33c6ce7ef30c13f7ed860671705982f9d9cd48a18cc0042894d0fe"} +{"hash_algorithm":"sha256","ts":"2026-10-23T16:32:21.672Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"81dec87e440efc4f024215d0","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"tests_add","patch":[{"op":"add","path":"/metadata/tests/1","value":{"command":"npm run release:check","scope":"project","timeout_seconds":7200,"provenance":{"author":"codex-agent","created_at":"2026-10-23T16:32:21.591Z","source_kind":"local_mutation","source_ref":"fix/release-merge-wait-fits-ci"}}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-23T16:32:21.672Z"}],"before_hash":"6c3489ae98cddd15fb8e26cb2c82b262416e855cbcbf8b33af5f0d03042c5930","after_hash":"53b5e04597f50ef55d8dd121283ac41266ab81ae90ff7c867ff3df64395715ec","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"f289af721b88cad6bbc3d56246fe2e38a1bdc64152cf6724f45403003d849e9c"} +{"hash_algorithm":"sha256","ts":"2026-10-23T16:32:23.813Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"81dec87e440efc4f024215d0","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"tests_add","patch":[{"op":"add","path":"/metadata/tests/2","value":{"command":"bun run release:check","scope":"project","timeout_seconds":7200,"provenance":{"author":"codex-agent","created_at":"2026-10-23T16:32:23.701Z","source_kind":"local_mutation","source_ref":"fix/release-merge-wait-fits-ci"}}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-23T16:32:23.813Z"}],"before_hash":"53b5e04597f50ef55d8dd121283ac41266ab81ae90ff7c867ff3df64395715ec","after_hash":"274d85e2240ba320b7cc8d4a0b7bd0c93cdb52ae33a58f906618111b5395468a","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"f56d5d2a9ff70004d1be705d210853217e1696204d853bfef0a12de492b8a5cb"} +{"hash_algorithm":"sha256","ts":"2026-10-23T17:04:57.175Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"81dec87e440efc4f024215d0","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/4","value":{"created_at":"2026-10-23T17:04:57.174Z","author":"codex-agent","text":"Both required full gates passed: npm run release:check and bun run release:check each ran 1045 tests with zero failures or skips and exact 100/100/100/100 coverage across 19 authored modules. Both also passed typecheck/build, docstrings, lint, zero duplication, privacy, production audit, package dry run, changelog and publish-attestation checks. The independently installed Bun validation worktree used byte-identical source, test and documentation candidate files; npm and Bun package dry runs produced the same package hash. Workflow-focused pm test passed 60/60 and native Bun passed the 24 new regressions. Four behavioral revert controls failed as expected. Remaining validation is the PR CI matrix; live Daily Release dispatch belongs to the orchestrator after merge. Keep this item in progress for orchestrator verification."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-23T17:04:57.175Z"}],"before_hash":"274d85e2240ba320b7cc8d4a0b7bd0c93cdb52ae33a58f906618111b5395468a","after_hash":"f6b99cac3b417fcae5e0eea1c5d5e3277cc7888649ab6c36170c086d29d7b949","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"4e5181937348c921d0b1f9c5ebc1f7cb0793a8bf8db8e084dccced6bb982bb65"} +{"hash_algorithm":"sha256","ts":"2026-10-23T17:04:59.966Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"81dec87e440efc4f024215d0","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"release","patch":[{"op":"remove","path":"/metadata/claim_principal"},{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-23T17:04:59.966Z"}],"before_hash":"f6b99cac3b417fcae5e0eea1c5d5e3277cc7888649ab6c36170c086d29d7b949","after_hash":"98c35dcc1967602e77f84e9e121e9e96e2c12be809372189d894cd1f298f6808","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"b9024d365aa80f4fc01ebd3509b430b4119f2dc5815ced3558bb24699b3d8ba6"} diff --git a/.agents/pm/issues/pm-github-release-merge-wait.toon b/.agents/pm/issues/pm-github-release-merge-wait.toon new file mode 100644 index 00000000..e442856a --- /dev/null +++ b/.agents/pm/issues/pm-github-release-merge-wait.toon @@ -0,0 +1,28 @@ +id: pm-github-release-merge-wait +title: Derive release merge wait from CI duration and refresh a safe main advance once +description: "Daily Release PR #171 exceeded the fixed 30-minute wait while required Node 22 and 26 tests were still running. Measure recent successful main CI job durations, report the bounded wait derivation, and permit one protected release-branch update when intervening commits preserve release metadata." +type: Issue +status: in_progress +priority: 1 +tags[3]: changelog-unreleased,ci,release +created_at: "2026-10-23T15:47:55.215Z" +updated_at: "2026-10-23T17:04:59.966Z" +author: codex-agent +acceptance_criteria: "Workflow consumes tested decision helpers; wait floor is 60 minutes with a cap below the job timeout; one safe refresh restarts checks; release metadata drift, conflicts and repeated main advances abort; npm and Bun release gates pass." +comments[5]{created_at,author,text}: + "2026-10-23T15:47:59.125Z",codex-agent,"Plan: use 20 successful main CI runs and the successful test (22)/(26) job p95 plus max(15 minutes, 25 percent), bounded to 60-120 minutes under an explicit 360-minute job timeout. Inspect every intervening commit for package version or release-artifact changes before one leased branch refresh; restart the wait once. Use the workflow shell and real temporary git repositories for regression and revert proof." + "2026-10-23T15:54:59.525Z",codex-agent,"Live read-only execution of the workflow measurement step returned 40 successful Node 22/26 job samples from 20 main push CI runs: p95 1859 seconds plus 900 seconds margin, yielding the 3600-second floor. Focused regressions passed 24/24 and helper coverage was 100/100/100/100. Behavioral revert proof: fixed 30-minute wait failed 2 tests; bypassed metadata guard failed 8; restored abort-on-main-advance failed 3; omitted deadline restart failed 1. All mutants were restored. Rebase preserves a linear protected PR and each updated push uses the previously prepared head as its lease." + "2026-10-23T15:56:03.826Z",codex-agent,"The linked pm test runner exposed an inherited tracker-selection environment in the new no-git fixture. The fixture now passes its own explicit tracker path to pm init, so it cannot select or mutate an enclosing tracker. The 60 release workflow tests now pass through pm test, including genuine package/lock dependency updates alongside the prepared version bump." + "2026-10-23T16:32:19.971Z",codex-agent,"The complete npm release gate passed with isolated HOME and git configuration: 1045 tests, zero failures or skips, exact 100/100/100/100 coverage over 19 authored modules, 188 documented declarations, zero source duplication, approved identities and no privacy findings, production audit, package dry run, changelog check and publish-attestation check. Strict tracked-project health also passed with isolated HOME. The required Bun release gate is running against the byte-identical source candidate in an independent validation worktree so build and coverage receipts cannot collide. Native Bun already passed all 24 new regressions." + "2026-10-23T17:04:57.174Z",codex-agent,"Both required full gates passed: npm run release:check and bun run release:check each ran 1045 tests with zero failures or skips and exact 100/100/100/100 coverage across 19 authored modules. Both also passed typecheck/build, docstrings, lint, zero duplication, privacy, production audit, package dry run, changelog and publish-attestation checks. The independently installed Bun validation worktree used byte-identical source, test and documentation candidate files; npm and Bun package dry runs produced the same package hash. Workflow-focused pm test passed 60/60 and native Bun passed the 24 new regressions. Four behavioral revert controls failed as expected. Remaining validation is the PR CI matrix; live Daily Release dispatch belongs to the orchestrator after merge. Keep this item in progress for orchestrator verification." +files[3]{path,scope,note}: + .github/workflows/release.yml,project,Measured wait and one guarded release-base refresh + scripts/release-merge-policy.ts,project,CI timing and per-commit metadata policy + test/release-merge-policy.test.ts,project,Real git fixtures and complete workflow-shell regressions +tests[3]{command,scope,timeout_seconds,provenance{author,created_at,source_kind,source_ref}}: + node --test test/release-merge-policy.test.ts test/daily-release-plan.test.ts test/release-approval.test.ts test/release-workflow.test.ts,project,180,codex-agent,"2026-10-23T15:54:24.865Z",local_mutation,fix/release-merge-wait-fits-ci + "npm run release:check",project,7200,codex-agent,"2026-10-23T16:32:21.591Z",local_mutation,fix/release-merge-wait-fits-ci + "bun run release:check",project,7200,codex-agent,"2026-10-23T16:32:23.701Z",local_mutation,fix/release-merge-wait-fits-ci +docs[1]{path,scope,note}: + README.md,project,Daily Release wait derivation and safe refresh limits +body: "" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 856230d0..d6fa1a0c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,6 +28,8 @@ jobs: # refusal could fire. The step check stays as defence in depth. if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest + # At most two 120-minute waits, leaving time for preparation and verification. + timeout-minutes: 360 env: RELEASE_TIMEZONE: Europe/Vienna steps: @@ -281,12 +283,51 @@ jobs: git commit -m "Release ${REPO_NAME} ${RELEASE_TAG}" fi + - name: Measure release merge wait + if: steps.decide.outputs.should_release == 'true' + id: merge_wait + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + timings="$(mktemp)" + trap 'rm -f "$timings"' EXIT + # Only successful push runs on main, never PR/fork timing samples. + if run_ids="$(gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/ci.yml/runs?branch=main&event=push&status=success&per_page=20" \ + --jq '.workflow_runs[].id')"; then + for run_id in $run_ids; do + if ! gh api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/jobs?filter=latest&per_page=100" \ + --jq '.jobs[] | {name,conclusion,started_at,completed_at}' >> "$timings"; then + echo "::warning::Could not measure CI run ${run_id}; using available samples." + fi + done + else + echo "::warning::CI history unavailable; using the conservative 60-minute floor." + fi + jq -s '.' "$timings" > "${timings}.json" + trap 'rm -f "$timings" "${timings}.json"' EXIT + node --input-type=module -e ' + import { readFileSync, appendFileSync } from "node:fs"; + import { deriveReleaseMergeWait } from "./scripts/release-merge-policy.ts"; + const plan = deriveReleaseMergeWait(JSON.parse(readFileSync(process.argv[1], "utf8"))); + appendFileSync(process.env.GITHUB_OUTPUT, "wait_seconds=" + plan.wait_seconds + "\n"); + const summary = "Release merge wait: " + plan.wait_seconds / 60 + " minutes. " + + "Last 20 successful main push CI runs; successful test (22)/(26) samples: " + plan.sample_count + + "; nearest-rank p95: " + plan.p95_seconds + "s; margin: max(900s, 25% of p95) = " + plan.margin_seconds + + "s; floor: 60m; cap: 120m; job timeout: 360m. One safe refresh restarts this wait.\n"; + console.log(summary); + try { appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary); } + catch { console.log("::warning::Could not write merge wait summary."); } + ' "${timings}.json" + - name: Merge release metadata through protected PR if: steps.decide.outputs.should_release == 'true' id: release_pr shell: bash env: GH_TOKEN: ${{ github.token }} + MERGE_WAIT_SECONDS: ${{ steps.merge_wait.outputs.wait_seconds }} RELEASE_BASE_SHA: ${{ steps.decide.outputs.base_sha }} RELEASE_TAG: ${{ steps.decide.outputs.tag }} NPM_VERSION: ${{ steps.decide.outputs.npm_version }} @@ -294,15 +335,36 @@ jobs: RELEASE_PR_NUMBER: ${{ steps.decide.outputs.pr_number }} run: | set -euo pipefail - git fetch origin main --force - current_main_sha="$(git rev-parse origin/main)" - if [[ "$current_main_sha" != "$RELEASE_BASE_SHA" ]]; then - echo "::error::main advanced from ${RELEASE_BASE_SHA} to ${current_main_sha} while preparing the release. Retry from the new main head." + release_branch="$RELEASE_BRANCH" + pr_number="$RELEASE_PR_NUMBER" + release_base_sha="$RELEASE_BASE_SHA" + refreshed=0 + merge_wait_seconds="${MERGE_WAIT_SECONDS:-3600}" + if [[ ! "$merge_wait_seconds" =~ ^[0-9]+$ ]] || (( merge_wait_seconds < 3600 || merge_wait_seconds > 7200 )); then + echo "::error::Release merge wait must be between 60 and 120 minutes." exit 1 fi + # One refresh is shared by preparation and the entire wait, never one per poll. + refresh_release_base() { + git fetch origin main --force + current_main_sha="$(git rev-parse origin/main)" + if [[ "$current_main_sha" == "$release_base_sha" ]]; then return; fi + if (( refreshed )); then + echo "::error::main advanced again after the single release refresh; retry release preparation." + exit 1 + fi + node --input-type=module -e ' + import { assertSafeReleaseAdvance } from "./scripts/release-merge-policy.ts"; + assertSafeReleaseAdvance(process.argv[1], process.argv[2]); + ' "$release_base_sha" "$current_main_sha" + # A conflict fails closed; rebase keeps the protected release PR linear. + git rebase "$current_main_sha" + release_base_sha="$current_main_sha" + refreshed=1 + echo "Updated release branch from main once; required CI must pass on the new head." + } + refresh_release_base - release_branch="$RELEASE_BRANCH" - pr_number="$RELEASE_PR_NUMBER" remote_branch_sha="$(git ls-remote --heads origin "refs/heads/${release_branch}" | cut -f1)" if [[ -z "$pr_number" && -n "$remote_branch_sha" ]]; then echo "::error::Refusing to overwrite a release branch without a verified workflow PR." @@ -342,14 +404,14 @@ jobs: owner="${GITHUB_REPOSITORY%/*}" repo="${GITHUB_REPOSITORY#*/}" - deadline=$(( SECONDS + 1800 )) + deadline=$(( SECONDS + merge_wait_seconds )) awaiting_approval=0 merge_err="$(mktemp)" pr_view_err="$(mktemp)" merged_sha="" while :; do # Read mergeStateStatus + statusCheckRollup only to detect the - # fatal DIRTY/BEHIND states and to keep the deadline message + # fatal DIRTY state and to keep the deadline message # diagnosable. The merge decision is NOT derived from this state # (see the step-level comment above): the merge API call is the # authority. `set -e` is active, so an unguarded `gh pr view` @@ -387,12 +449,25 @@ jobs: fi case "$merge_state" in - DIRTY | BEHIND) - echo "::error::Release PR #${pr_number} is ${merge_state}; it conflicts with main or its base moved. Retry the release from the new main head." + DIRTY) + echo "::error::Release PR #${pr_number} is DIRTY; it conflicts with main. Retry the release from the new main head." exit 1 ;; esac + previous_refresh="$refreshed" + refresh_release_base + if [[ "$refreshed" != "$previous_refresh" ]]; then + updated_commit="$(git rev-parse HEAD)" + git push --force-with-lease="refs/heads/${release_branch}:${release_commit}" \ + origin "HEAD:refs/heads/${release_branch}" + release_commit="$updated_commit" + deadline=$(( SECONDS + merge_wait_seconds )) + # Re-read checks on the new head; never merge from the old rollup. + continue + fi + # BEHIND can lag a fetched main ref; retry without consuming a refresh. + # required_conversation_resolution is enabled, so any unresolved # review thread blocks the merge forever. Advisory bot reviewers # (Sourcery, cubic, CodeRabbit) routinely open threads as a @@ -480,11 +555,11 @@ jobs: if (( SECONDS >= deadline )); then if [[ "$awaiting_approval" == 1 ]]; then - echo "::error::Release PR #${pr_number} did not merge within 30 minutes because its CI run is awaiting workflow approval (last state: ${merge_state}). Approve the run linked above once; this repository requires approval for a contributor that has no merged PR yet. Nothing was published or tagged." + echo "::error::Release PR #${pr_number} did not merge within $(( merge_wait_seconds / 60 )) minutes because its CI run is awaiting workflow approval (last state: ${merge_state}). Approve the run linked above once; this repository requires approval for a contributor that has no merged PR yet. Nothing was published or tagged." elif [[ "$awaiting_approval" == unknown ]]; then - echo "::error::Release PR #${pr_number} did not merge within 30 minutes; current workflow approval state is unavailable (last state: ${merge_state}; still settling: ${unsettled:-none}; failing: ${failing:-none}). Inspect workflow runs and required checks; nothing was published or tagged." + echo "::error::Release PR #${pr_number} did not merge within $(( merge_wait_seconds / 60 )) minutes; current workflow approval state is unavailable (last state: ${merge_state}; still settling: ${unsettled:-none}; failing: ${failing:-none}). Inspect workflow runs and required checks; nothing was published or tagged." else - echo "::error::Release PR #${pr_number} did not merge within 30 minutes (last state: ${merge_state}; still settling: ${unsettled:-none}; failing: ${failing:-none}). A required check failed or never reported, or a required review is missing; not merging or publishing." + echo "::error::Release PR #${pr_number} did not merge within $(( merge_wait_seconds / 60 )) minutes (last state: ${merge_state}; still settling: ${unsettled:-none}; failing: ${failing:-none}). A required check failed or never reported, or a required review is missing; not merging or publishing." fi exit 1 fi @@ -573,9 +648,8 @@ jobs: echo "::warning::Could not write release summary: ${summary_error}" fi - # The release PR can only contain the prepared release commit because the - # base SHA is checked immediately before creation. Re-check the exact - # merged main commit anyway so npm always receives byte-for-byte validated + # Preparation and the optional main refresh preserve release metadata. + # Re-check the exact merged main commit so npm always receives byte-for-byte validated # repository state, and fail safely before publication if main moved. - name: Verify merged release if: steps.decide.outputs.should_release == 'true' diff --git a/CHANGELOG.md b/CHANGELOG.md index 047f44fe..3fa00ba7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ ### Fixed +- Derive release merge wait from CI duration and refresh a safe main advance once ([pm-github-release-merge-wait](https://github.com/unbraind/pm-github/blob/main/.agents/pm/issues/pm-github-release-merge-wait.toon)) - A publish spelled through a package runner was invisible to the attestation gate, so an unattested one read as clean ([pm-github-5igz](https://github.com/unbraind/pm-github/blob/main/.agents/pm/issues/pm-github-5igz.toon)) - PM CLI filtered native Bun tests falsely report an empty run ([pm-github-2ao1](https://github.com/unbraind/pm-github/blob/main/.agents/pm/issues/pm-github-2ao1.toon)) - Track pm-changelog rendering closed historical incidents as Fixed ([pm-github-n0mm](https://github.com/unbraind/pm-github/blob/main/.agents/pm/issues/pm-github-n0mm.toon)) diff --git a/README.md b/README.md index 734ecf78..aad50122 100644 --- a/README.md +++ b/README.md @@ -941,6 +941,10 @@ Release checks require type checking, docstrings, ESLint, zero source duplicatio `npm run changelog:full` reads the complete tracker through the SDK and feeds pm-changelog. Closed work supplies release history; the explicit `changelog-unreleased` tag includes a pending candidate without closing its PM item. Other open work stays excluded. +Daily Release measures the successful `test (22)` and `test (26)` jobs from the last 20 successful main push CI runs. Its merge wait is the nearest-rank p95 duration plus the larger of 15 minutes or 25%, bounded to 60–120 minutes. Missing history uses the 60-minute floor. The log and job summary record the samples, p95, margin and bounds; the job timeout is 360 minutes. + +A main advance during preparation or waiting can update the release branch once. Every intervening commit must preserve `package.json` and lockfile release versions, `CHANGELOG.md`, `manifest.json`, and release notes, including changes later reverted. Dependency-only changes are eligible. A conflict, metadata change, non-ancestor main, or second advance aborts. A successful update uses a head-specific push lease, restarts the full measured wait, and requires protected checks on the new head. Publication still verifies the merged commit and runs the release gate again. + ## Multi-agent merge safety This repo tracks its project management in `.agents/pm/` and ships a committed `.gitattributes` diff --git a/scripts/release-merge-policy.ts b/scripts/release-merge-policy.ts new file mode 100644 index 00000000..ec2eff41 --- /dev/null +++ b/scripts/release-merge-policy.ts @@ -0,0 +1,54 @@ +/** Measured CI waits and conservative release-base refresh guards. */ +import { execFileSync, spawnSync } from "node:child_process"; + +/** Minimal successful-job timing fields returned by the GitHub Actions API. */ +export interface ReleaseTimingJob { + name: string; + conclusion: string | null; + started_at: string | null; + completed_at: string | null; +} + +/** Derive a nearest-rank p95 wait with queue/review margin and explicit bounds. */ +export function deriveReleaseMergeWait(jobs: readonly ReleaseTimingJob[]) { + const samples = jobs.filter(job => ["test (22)", "test (26)"].includes(job.name) && job.conclusion === "success") + .map(job => (Date.parse(job.completed_at ?? "") - Date.parse(job.started_at ?? "")) / 1000) + .filter(seconds => Number.isFinite(seconds) && seconds > 0).sort((a, b) => a - b); + const p95Seconds = samples.length === 0 ? 0 : Math.ceil(samples[Math.ceil(samples.length * 0.95) - 1]!); + const marginSeconds = Math.max(900, Math.ceil(p95Seconds * 0.25)); + const waitSeconds = Math.min(7200, Math.max(3600, p95Seconds + marginSeconds)); + return { sample_count: samples.length, p95_seconds: p95Seconds, margin_seconds: marginSeconds, wait_seconds: waitSeconds }; +} + +/** Read a repository fact without invoking a shell or accepting option-shaped refs. */ +function gitFact(directory: string, args: string[]): string { + return execFileSync("git", args, { cwd: directory, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); +} + +/** Refuse metadata changes in every new commit, including changes later reverted. */ +export function assertSafeReleaseAdvance(base: string, main: string, directory = process.cwd()): void { + const repository = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd: directory, encoding: "utf8" }); + if (repository.status !== 0 || repository.stdout.trim() !== "true") { + throw new Error("Release branch refresh needs a git repository; the tracker can still be used without git."); + } + if (!/^[a-f0-9]{40}$/.test(base) || !/^[a-f0-9]{40}$/.test(main)) throw new Error("Release refresh requires full commit SHAs."); + if (spawnSync("git", ["merge-base", "--is-ancestor", base, main], { cwd: directory }).status !== 0) { + throw new Error("Release base is not an ancestor of main; retry release preparation."); + } + const commits = gitFact(directory, ["rev-list", `${base}..${main}`]).split("\n").filter(Boolean); + for (const commit of commits) { + const parent = gitFact(directory, ["rev-parse", `${commit}^`]); + const paths = gitFact(directory, ["diff", "--name-only", "--no-renames", parent, commit, "--"]).split("\n"); + if (paths.some(path => ["CHANGELOG.md", "manifest.json", "RELEASE_NOTES.md"].includes(path))) { + throw new Error("Main changed release metadata; retry release preparation."); + } + for (const path of ["package.json", "package-lock.json"]) { + if (!paths.includes(path)) continue; + const previous = JSON.parse(gitFact(directory, ["show", `${parent}:${path}`])) as { version?: string; packages?: Record }; + const next = JSON.parse(gitFact(directory, ["show", `${commit}:${path}`])) as typeof previous; + if (previous.version !== next.version || previous.packages?.[""]?.version !== next.packages?.[""]?.version) { + throw new Error("Main changed the package release version; retry release preparation."); + } + } + } +} diff --git a/test/release-merge-policy.test.ts b/test/release-merge-policy.test.ts new file mode 100644 index 00000000..53ca10bb --- /dev/null +++ b/test/release-merge-policy.test.ts @@ -0,0 +1,290 @@ +/** Exercise the policy consumed by the release workflow, with real commit history. */ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { devNull, tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { assertSafeReleaseAdvance, deriveReleaseMergeWait, type ReleaseTimingJob } from "../scripts/release-merge-policy.ts"; + +/** Construct an API job with a precise positive duration. */ +function job(seconds: number, name = "test (22)"): ReleaseTimingJob { + return { name, conclusion: "success", started_at: "2026-10-22T00:00:00Z", completed_at: new Date(Date.parse("2026-10-22T00:00:00Z") + seconds * 1000).toISOString() }; +} + +test("wait uses nearest-rank p95, measured margin, floor and cap", () => { + assert.deepEqual(deriveReleaseMergeWait([]), { sample_count: 0, p95_seconds: 0, margin_seconds: 900, wait_seconds: 3600 }); + assert.equal(deriveReleaseMergeWait([job(2300, "test (26)")]).wait_seconds, 3600); + assert.deepEqual(deriveReleaseMergeWait(Array.from({ length: 20 }, (_, i) => job((i + 1) * 200))), + { sample_count: 20, p95_seconds: 3800, margin_seconds: 950, wait_seconds: 4750 }); + assert.equal(deriveReleaseMergeWait([job(10_000)]).wait_seconds, 7200); + assert.equal(deriveReleaseMergeWait([job(3000.1)]).p95_seconds, 3001); +}); + +test("wait ignores unrelated, failed, missing, malformed and nonpositive timings", () => { + assert.equal(deriveReleaseMergeWait([ + job(9000, "bun-release (22)"), { ...job(9000), conclusion: "failure" }, + { ...job(9000), started_at: null }, { ...job(9000), completed_at: null }, + { ...job(9000), started_at: "bad-date" }, job(0), job(-1), job(100), + ]).sample_count, 1); +}); + +/** A git fixture with isolated identity, ordinary dependency data and release metadata. */ +class ReleaseRepository { + root = mkdtempSync(join(tmpdir(), "release-merge-policy-")); + env: NodeJS.ProcessEnv = { ...process.env, HOME: this.root, GIT_CONFIG_GLOBAL: devNull, GIT_CONFIG_NOSYSTEM: "1", GIT_CEILING_DIRECTORIES: tmpdir() }; + base: string; + + /** Initialize an independent repository so ancestor discovery cannot hide a missing git boundary. */ + constructor() { + this.git("init", "-b", "main"); + this.git("config", "user.name", "Synthetic Release"); + this.git("config", "user.email", "release@example.invalid"); + for (const path of ["package.json", "package-lock.json"]) this.write(path, JSON.stringify({ version: "1.0.0", dependencies: { example: "1" }, packages: { "": { version: "1.0.0" } } }, null, 2) + "\n"); + this.write("manifest.json", '{"version":"1.0.0"}\n'); + this.write("CHANGELOG.md", "Initial\n"); + this.write("README.md", "Initial\n"); + this.base = this.commit(); + } + + /** Run git using only the fixture's configuration. */ + git(...args: string[]): string { + return execFileSync("git", args, { cwd: this.root, env: this.env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); + } + + /** Write a synthetic tracked artifact. */ + write(path: string, content: string): void { writeFileSync(join(this.root, path), content); } + + /** Commit explicit fixture paths and return their complete object identity. */ + commit(): string { + this.git("add", "package.json", "package-lock.json", "manifest.json", "CHANGELOG.md", "README.md"); + this.git("commit", "--allow-empty", "-m", "Synthetic change"); + return this.git("rev-parse", "HEAD"); + } + + /** Remove only the temporary fixture. */ + dispose(): void { rmSync(this.root, { recursive: true, force: true }); } +} + +test("release refresh needs a git repository and full, ancestor commit identities", () => { + const repo = new ReleaseRepository(); + const plain = mkdtempSync(join(tmpdir(), "release-plain-tracker-")); + const originalCeiling = process.env.GIT_CEILING_DIRECTORIES; + process.env.GIT_CEILING_DIRECTORIES = tmpdir(); + try { + const init = spawnSync(process.execPath, [join(import.meta.dirname, "../node_modules/@unbrained/pm-cli/dist/cli.js"), "init", "--pm-path", join(plain, ".agents/pm"), "--prefix", "synthetic", "--defaults", "--agent-guidance", "skip"], + { cwd: plain, env: { ...repo.env, GIT_CEILING_DIRECTORIES: tmpdir() }, encoding: "utf8" }); + assert.equal(init.status, 0, init.stderr); + assert.throws(() => assertSafeReleaseAdvance(repo.base, repo.base, plain), /needs a git repository/); + assert.throws(() => assertSafeReleaseAdvance("--help", repo.base, repo.root), /full commit SHAs/); + assert.throws(() => assertSafeReleaseAdvance(repo.base, "bad-ref", repo.root), /full commit SHAs/); + const next = repo.commit(); + assert.throws(() => assertSafeReleaseAdvance(next, repo.base, repo.root), /not an ancestor/); + } finally { + if (originalCeiling === undefined) delete process.env.GIT_CEILING_DIRECTORIES; + else process.env.GIT_CEILING_DIRECTORIES = originalCeiling; + repo.dispose(); rmSync(plain, { recursive: true, force: true }); + } +}); + +test("dependency updates preserve package versions and are safe with or without lock packages", () => { + const repo = new ReleaseRepository(); + const originalDirectory = process.cwd(); + try { + for (const path of ["package.json", "package-lock.json"]) { + repo.write(path, JSON.stringify({ version: "1.0.0", dependencies: { example: "2" }, packages: { "": { version: "1.0.0" } } })); + } + const next = repo.commit(); + assert.doesNotThrow(() => assertSafeReleaseAdvance(repo.base, next, repo.root)); + for (const path of ["package.json", "package-lock.json"]) repo.write(path, JSON.stringify({ version: "1.0.0", dependencies: { example: "3" } })); + const last = repo.commit(); + // Removing the root lock version is release drift even when the top-level value remains. + assert.throws(() => assertSafeReleaseAdvance(next, last, repo.root), /package release version/); + repo.git("reset", "--hard", repo.base); + for (const path of ["package.json", "package-lock.json"]) repo.write(path, JSON.stringify({ version: "1.0.0", dependencies: { example: "3" } })); + const withoutPackages = repo.commit(); + for (const path of ["package.json", "package-lock.json"]) repo.write(path, JSON.stringify({ version: "1.0.0", dependencies: { example: "4" } })); + const dependency = repo.commit(); + process.chdir(repo.root); + assert.doesNotThrow(() => assertSafeReleaseAdvance(withoutPackages, dependency)); + assert.doesNotThrow(() => assertSafeReleaseAdvance(dependency, dependency)); + } finally { process.chdir(originalDirectory); repo.dispose(); } +}); + +for (const artifact of ["package.json", "package-lock.json", "manifest.json", "CHANGELOG.md", "RELEASE_NOTES.md", "root lock version"]) { + test(`release refresh refuses ${artifact} drift even if a later commit restores the original`, () => { + const repo = new ReleaseRepository(); + try { + const path = artifact === "root lock version" ? "package-lock.json" : artifact; + const original = existsSync(join(repo.root, path)) ? readFileSync(join(repo.root, path), "utf8") : null; + if (artifact === "root lock version") repo.write(path, JSON.stringify({ version: "1.0.0", packages: { "": { version: "2.0.0" } } })); + else if (path.endsWith(".json")) repo.write(path, '{"version":"2.0.0"}\n'); + else repo.write(path, "New release\n"); + if (path === "RELEASE_NOTES.md") repo.git("add", path); + const changed = repo.commit(); + assert.throws(() => assertSafeReleaseAdvance(repo.base, changed, repo.root), /release (metadata|version)/); + if (original === null) repo.git("rm", path); + else repo.write(path, original); + const restored = repo.commit(); + assert.throws(() => assertSafeReleaseAdvance(repo.base, restored, repo.root), /release (metadata|version)/); + } finally { repo.dispose(); } + }); +} + +/** Parse the workflow rather than duplicating its executable shell logic. */ +const { parse }: { parse: (source: string) => { jobs: { release: { "timeout-minutes": number; steps: { name: string; run?: string; env?: Record }[] } } } } = + createRequire(createRequire(import.meta.url).resolve("@unbrained/pm-cli/package.json"))("yaml"); +const release = parse(readFileSync(new URL("../.github/workflows/release.yml", import.meta.url), "utf8")).jobs.release; + +/** Load the actual shell block executed by the release job. */ +function workflowScript(name: string): string { + const script = release.steps.find(step => step.name === name)?.run; + assert.ok(script, `Missing workflow script: ${name}`); + return script; +} + +/** Execute the measurement step with file-backed GitHub API output. */ +function measure(scenario: string) { + const root = mkdtempSync(join(tmpdir(), "release-measure-")); + try { + const result = spawnSync("bash", ["-c", ` + gh() { + if [[ "$*" == *workflows/ci.yml/runs* ]]; then + [[ "$*" == *'branch=main&event=push&status=success&per_page=20'* ]] || return 99 + if [[ "$SCENARIO" == unavailable ]]; then return 1; fi + printf '11\\n12\\n' + elif [[ "$*" == *'/11/jobs?'* ]]; then + if [[ "$SCENARIO" == partial ]]; then return 1; fi + printf '%s' "$TEST_JOBS" + elif [[ "$*" == *'/12/jobs?'* ]]; then printf '%s' "$TEST_JOBS"; else return 99; fi + } + ${workflowScript("Measure release merge wait")} + `], { cwd: join(import.meta.dirname, ".."), encoding: "utf8", env: { ...process.env, TMPDIR: root, + SCENARIO: scenario, TEST_JOBS: JSON.stringify(job(4000)), GITHUB_REPOSITORY: "fixture/package", + GITHUB_OUTPUT: join(root, "outputs"), GITHUB_STEP_SUMMARY: scenario === "summary-failure" ? join(root, "missing/summary") : join(root, "summary") } }); + return { ...result, outputs: readFileSync(join(root, "outputs"), "utf8"), + summary: existsSync(join(root, "summary")) ? readFileSync(join(root, "summary"), "utf8") : "" }; + } finally { rmSync(root, { recursive: true, force: true }); } +} + +test("workflow measures real API job fields and records its derivation with safe history fallbacks", () => { + assert.equal(release["timeout-minutes"], 360); + assert.equal(release.steps.find(step => step.name === "Merge release metadata through protected PR")?.env?.MERGE_WAIT_SECONDS, "${{ steps.merge_wait.outputs.wait_seconds }}"); + for (const scenario of ["success", "partial", "unavailable", "summary-failure"]) { + const result = measure(scenario); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.outputs, `wait_seconds=${scenario === "unavailable" ? 3600 : 5000}\n`); + assert.match(result.stdout, /nearest-rank p95: .*floor: 60m; cap: 120m; job timeout: 360m/); + if (scenario === "summary-failure") assert.match(result.stdout, /Could not write merge wait summary/); + else assert.match(result.summary, new RegExp(`samples: ${scenario === "success" ? 2 : scenario === "partial" ? 1 : 0}`)); + } +}); + +/** Run the merge shell with real branches and an isolated origin, never a live mutation. */ +function merging(scenario: string) { + const repo = new ReleaseRepository(); + try { + mkdirSync(join(repo.root, "scripts")); + copyFileSync(new URL("../scripts/release-merge-policy.ts", import.meta.url), join(repo.root, "scripts/release-merge-policy.ts")); + const origin = join(repo.root, "origin.git"); + repo.git("init", "--bare", origin); + repo.git("remote", "add", "origin", origin); + repo.git("push", "origin", "main"); + repo.git("checkout", "-b", "release/2026.10.23"); + repo.write("CHANGELOG.md", "Prepared release\n"); + for (const path of ["package.json", "package-lock.json"]) repo.write(path, JSON.stringify({ version: "2.0.0", dependencies: { example: "1" }, packages: { "": { version: "2.0.0" } } }, null, 2) + "\n"); + repo.write("manifest.json", '{"version":"2.0.0"}\n'); + if (scenario === "merge-conflict") repo.write("README.md", "Prepared conflicting readme\n"); + const prepared = repo.commit(); + repo.git("checkout", "main"); + if (scenario === "metadata" || scenario === "metadata-wait") repo.write("manifest.json", '{"version":"2.0.0"}\n'); + else if (scenario === "version") repo.write("package.json", '{"version":"2.0.0"}\n'); + else if (scenario === "conflict") repo.write("CHANGELOG.md", "Conflicting release\n"); + else { + repo.write("README.md", "New main\n"); + for (const path of ["package.json", "package-lock.json"]) repo.write(path, JSON.stringify({ version: "1.0.0", dependencies: { example: "2" }, packages: { "": { version: "1.0.0" } } }, null, 2) + "\n"); + } + const advanced = repo.commit(); + repo.write("README.md", "Main advanced twice\n"); + const second = repo.commit(); + repo.git("checkout", "--detach", prepared); + if (["preparation", "metadata", "version", "conflict"].includes(scenario)) repo.git("push", "origin", `${advanced}:refs/heads/main`); + const output = join(repo.root, "outputs"); + const result = spawnSync("bash", ["-c", ` + gh() { + case "$1 $2" in + 'pr edit') return 0 ;; + 'pr view') + poll=0; [[ ! -f "$POLL" ]] || read -r poll < "$POLL" + poll=$((poll+1)); printf '%s\\n' "$poll" > "$POLL" + if [[ "$SCENARIO" != preparation && "$SCENARIO" != stale && ( "$SCENARIO" != timeout || "$poll" -gt 1 ) ]]; then + command git push origin "$ADVANCED:refs/heads/main" >/dev/null 2>&1 || return + fi + if [[ "$SCENARIO" == twice && "$poll" -gt 1 ]]; then command git push origin "$SECOND:refs/heads/main" >/dev/null 2>&1 || return; fi + if [[ "$SCENARIO" == dirty ]]; then printf '{"merge_state":"DIRTY"}'; + elif [[ "$SCENARIO" == clean-advance ]]; then printf '{"merge_state":"CLEAN","unsettled":[],"failing":[]}'; + else printf '{"merge_state":"BEHIND","unsettled":["test (22)"],"failing":[]}'; fi ;; + 'api graphql') return 0 ;; + 'api --method') + printf '%s\\n' "$*" >> "$MERGES" + if [[ "$SCENARIO" == timeout ]]; then return 1; fi + printf '{"sha":"verified-main"}' ;; + *) return 99 ;; + esac + } + sleep() { SECONDS=$((SECONDS + 3700)); } + ${workflowScript("Merge release metadata through protected PR")} + `], { cwd: repo.root, encoding: "utf8", timeout: 30_000, env: { ...repo.env, TMPDIR: repo.root, + SCENARIO: scenario, ADVANCED: advanced, SECOND: second, POLL: join(repo.root, "poll"), MERGES: join(repo.root, "merges"), + RELEASE_BRANCH: "release/2026.10.23", RELEASE_PR_NUMBER: "171", RELEASE_BASE_SHA: repo.base, + RELEASE_TAG: "v2026.10.23", NPM_VERSION: "2026.10.23", GITHUB_REPOSITORY: "fixture/package", GITHUB_OUTPUT: output, + MERGE_WAIT_SECONDS: scenario === "invalid-wait" ? "1800" : "3600" } }); + const head = repo.git("rev-parse", "HEAD"); + return { ...result, outputs: existsSync(output) ? readFileSync(output, "utf8") : "", + merges: existsSync(join(repo.root, "merges")) ? readFileSync(join(repo.root, "merges"), "utf8") : "", + package: JSON.parse(readFileSync(join(repo.root, "package.json"), "utf8")) as { version: string; dependencies?: { example: string } }, + head, prepared, advanced, remote: repo.git("ls-remote", "origin", "refs/heads/release/2026.10.23") }; + } finally { repo.dispose(); } +} + +for (const scenario of ["preparation", "waiting", "clean-advance"]) { + test(`workflow safely refreshes once during ${scenario} and merges only the new head`, () => { + const result = merging(scenario); + assert.equal(result.status, 0, result.stderr); + assert.notEqual(result.head, result.prepared); + assert.equal(result.package.version, "2.0.0"); + assert.equal(result.package.dependencies?.example, "2"); + assert.match(result.stdout, /Updated release branch from main once/); + assert.match(result.merges, new RegExp(`-f sha=${result.head}`)); + assert.doesNotMatch(result.merges, new RegExp(`-f sha=${result.prepared}`)); + assert.match(result.remote, new RegExp(`^${result.head}`)); + assert.equal(result.outputs, "merged_sha=verified-main\npr_number=171\n"); + }); +} + +for (const scenario of ["metadata", "version", "metadata-wait", "conflict", "twice", "dirty", "invalid-wait", "merge-conflict"]) { + test(`workflow fails closed for ${scenario} without attempting a merge`, () => { + const result = merging(scenario); + assert.notEqual(result.status, 0); + assert.equal(result.outputs, ""); + assert.equal(result.merges, ""); + if (scenario === "twice") assert.match(result.stdout, /main advanced again/); + else if (["metadata", "version", "metadata-wait", "conflict"].includes(scenario)) assert.match(result.stderr, /Main changed/); + }); +} + +test("BEHIND without a newly fetched main commit waits without inventing a refresh", () => { + const result = merging("stale"); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.head, result.prepared); + assert.doesNotMatch(result.stdout, /Updated release branch/); +}); + +test("a safe refresh restarts the full measured wait instead of expiring on the old deadline", () => { + const result = merging("timeout"); + assert.equal(result.status, 1, result.stderr); + assert.match(result.stdout, /Updated release branch from main once/); + assert.match(result.stdout, /did not merge within 60 minutes/); + assert.ok(result.merges.split("\n").filter(Boolean).length >= 3); +});