diff --git a/.agents/pm/history/pm-github-v2kt.jsonl b/.agents/pm/history/pm-github-v2kt.jsonl new file mode 100644 index 0000000..8bb7c30 --- /dev/null +++ b/.agents/pm/history/pm-github-v2kt.jsonl @@ -0,0 +1,9 @@ +{"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"} diff --git a/.agents/pm/issues/pm-github-v2kt.toon b/.agents/pm/issues/pm-github-v2kt.toon new file mode 100644 index 0000000..93ebaad --- /dev/null +++ b/.agents/pm/issues/pm-github-v2kt.toon @@ -0,0 +1,20 @@ +id: pm-github-v2kt +title: Fix release publish ordering ahead of protected main push +description: "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." +type: Issue +status: open +priority: 2 +tags: [] +created_at: "2026-08-10T15:27:52.482Z" +updated_at: "2026-08-10T16:57:16.322Z" +author: pi-agent +notes[8]{created_at,author,text}: + "2026-08-10T15:27:52.883Z",pi-agent,"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." + "2026-08-10T15:30:44.660Z",claude,"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." + "2026-08-10T15:55:22.540Z",claude,"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." + "2026-08-10T16:03:35.223Z",claude,"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." + "2026-08-10T16:14:01.418Z",claude,"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." + "2026-08-10T16:23:19.099Z",claude,"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." + "2026-08-10T16:41:09.417Z",claude,"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." + "2026-08-10T16:57:16.322Z",claude,"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." +body: "" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1dcde4a..46b5173 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,11 @@ on: permissions: contents: write id-token: write + pull-requests: write + # Lets the merge wait approve a CI run that GitHub parked on `action_required` + # for the release PR. Best-effort: when the token may not approve, the run is + # still reported with its URL so a maintainer can approve it once. + actions: write concurrency: group: daily-release @@ -87,6 +92,7 @@ jobs: echo "latest_tag=$latest_tag" >> "$GITHUB_OUTPUT" echo "tag=$tag" >> "$GITHUB_OUTPUT" echo "npm_version=$npm_version" >> "$GITHUB_OUTPUT" + echo "base_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - name: Update release version if: steps.decide.outputs.should_release == 'true' @@ -146,6 +152,269 @@ jobs: exit 1 fi + - name: Merge release metadata through protected PR + if: steps.decide.outputs.should_release == 'true' + id: release_pr + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RELEASE_BASE_SHA: ${{ steps.decide.outputs.base_sha }} + RELEASE_TAG: ${{ steps.decide.outputs.tag }} + NPM_VERSION: ${{ steps.decide.outputs.npm_version }} + 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." + exit 1 + fi + + release_branch="release/${RELEASE_TAG#v}" + release_commit="$(git rev-parse HEAD)" + if [[ "$release_commit" == "$current_main_sha" ]]; then + echo "Release metadata is already present on main; resuming the publish/tag transaction." + if git ls-remote --exit-code --heads origin "refs/heads/${release_branch}" > /dev/null 2>&1; then + git push origin --delete "$release_branch" + fi + { + echo "merged_sha=$current_main_sha" + echo "pr_number=" + } >> "$GITHUB_OUTPUT" + exit 0 + fi + + remote_branch_sha="$(git ls-remote --heads origin "refs/heads/${release_branch}" | cut -f1)" + if [[ -n "$remote_branch_sha" ]]; then + git push \ + --force-with-lease="refs/heads/${release_branch}:${remote_branch_sha}" \ + origin "HEAD:refs/heads/${release_branch}" + else + git push origin "HEAD:refs/heads/${release_branch}" + fi + + pr_number="$(gh pr list \ + --repo "$GITHUB_REPOSITORY" \ + --base main \ + --head "$release_branch" \ + --state open \ + --json number \ + --jq '.[0].number // empty')" + if [[ -z "$pr_number" ]]; then + pr_url="$(gh pr create \ + --repo "$GITHUB_REPOSITORY" \ + --base main \ + --head "$release_branch" \ + --title "Release pm-github ${RELEASE_TAG}" \ + --body "Automated daily release metadata for \`${RELEASE_TAG}\`. The release gate passed before this PR was created; npm publication and tagging remain blocked until this protected PR is merged.")" + pr_number="${pr_url##*/}" + fi + + owner="${GITHUB_REPOSITORY%/*}" + repo="${GITHUB_REPOSITORY#*/}" + deadline=$(( SECONDS + 1800 )) + 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 + # 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` + # would abort the whole step on a transient 5xx/rate limit - a + # failed read is not information about the PR, so it is treated as + # an unrecognised state and retried. + pr_state="$(gh pr view "$pr_number" \ + --repo "$GITHUB_REPOSITORY" \ + --json mergeStateStatus,statusCheckRollup \ + --jq '{merge_state: (.mergeStateStatus // "UNKNOWN"), + unsettled: [.statusCheckRollup[]? + | {name: (.name // .context), + state: (.conclusion // .state // .status // "PENDING")} + | select((.state | IN("SUCCESS", "NEUTRAL", "SKIPPED", + "FAILURE", "ERROR", "TIMED_OUT", + "CANCELLED", "ACTION_REQUIRED", + "STARTUP_FAILURE", "STALE")) | not) + | .name], + failing: [.statusCheckRollup[]? + | {name: (.name // .context), + state: (.conclusion // .state // .status // "PENDING")} + | select(.state | IN("FAILURE", "ERROR", "TIMED_OUT", + "CANCELLED", "ACTION_REQUIRED", + "STARTUP_FAILURE", "STALE")) + | .name]}' 2>"$pr_view_err")" || pr_state="" + if [[ -z "$pr_state" ]]; then + echo "Could not read PR #${pr_number} (retrying): $(tr '\n' ' ' < "$pr_view_err")" + merge_state="UNKNOWN" + unsettled="github api unavailable" + failing="" + else + merge_state="$(jq -r '.merge_state' <<< "$pr_state")" + unsettled="$(jq -r '.unsettled | join(", ")' <<< "$pr_state")" + failing="$(jq -r '.failing | join(", ")' <<< "$pr_state")" + 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." + exit 1 + ;; + esac + + # 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 + # confidence signal; resolve every unresolved thread on this + # release PR before each attempt so a bot comment cannot dead-end + # the daily release. Only BOT-authored threads on this PR are + # resolved, and only when EVERY comment on the thread is bot- + # authored: a human reply on a bot-opened thread must keep + # blocking. Clearing a human reviewer's thread would remove the + # very protection required_conversation_resolution provides. + unresolved="$(gh api graphql \ + -f query='query($o:String!,$r:String!,$n:Int!){repository(owner:$o,name:$r){pullRequest(number:$n){reviewThreads(first:100){nodes{id isResolved comments(first:100){totalCount nodes{author{login __typename}}}}}}}}' \ + -F o="$owner" -F r="$repo" -F n="$pr_number" \ + --jq '.data.repository.pullRequest.reviewThreads.nodes[]? + | select(.isResolved==false) + | select(.comments.totalCount == + ([.comments.nodes[]?] | length)) + | select([.comments.nodes[]?.author.__typename] + | length > 0 and all(. == "Bot")) + | .id' 2>/dev/null || true)" + if [[ -n "$unresolved" ]]; then + while IFS= read -r thread_id; do + [[ -z "$thread_id" ]] && continue + gh api graphql \ + -f query='mutation($t:ID!){resolveReviewThread(input:{threadId:$t}){thread{isResolved}}}' \ + -F t="$thread_id" >/dev/null 2>&1 || true + done <<< "$unresolved" + fi + + # Attempt the merge and let GitHub be the authority. A successful + # PUT is proof the branch-protection rules were satisfied; a + # failure (405 = checks not ready, 409 = SHA moved) is proof they + # were not. No state inference in between. + merge_out="$(gh api --method PUT \ + "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}/merge" \ + -f merge_method=rebase \ + -f sha="$release_commit" \ + 2>"$merge_err")" && merge_rc=0 || merge_rc=$? + if (( merge_rc == 0 )); then + merged_sha="$(jq -r '.sha // empty' <<< "$merge_out" 2>/dev/null || true)" + if [[ -n "$merged_sha" && "$merged_sha" != "null" ]]; then + echo "Merged release PR #${pr_number} via merge API (state was ${merge_state})." + break + fi + # Merge reported success but no sha - treat as transient, retry. + echo "Merge returned no sha; retrying: $(tr '\n' ' ' < "$merge_err")" + merged_sha="" + else + echo "Merge attempt refused (state: ${merge_state}; settling: ${unsettled:-none}; failing: ${failing:-none}): $(tr '\n' ' ' < "$merge_err")" + + # A run parked on `action_required` is awaiting manual approval and + # will never start on its own. It is invisible in statusCheckRollup, + # so without this it is indistinguishable from a required check that + # failed: the loop just logs "settling: none" until the deadline. + # pm-changelog's release PR #133 parked exactly this way on + # 2026-08-10 (attempt 1 conclusion `action_required` at 04:54:24Z, + # policy `first_time_contributors`, and `github-actions[bot]` had no + # merged PR in that repo yet). A human re-ran it at 05:36Z, 12 + # minutes after the release had already given up. Try to approve it - + # the token often may not, which is harmless - and always surface the + # run so the wait is diagnosable. + pending_runs="$(gh api \ + "repos/${GITHUB_REPOSITORY}/actions/runs?head_sha=${release_commit}&per_page=100" \ + --jq '.workflow_runs[]? | select(.conclusion=="action_required" or .status=="waiting") | .id' \ + 2>/dev/null || true)" + if [[ -n "$pending_runs" ]]; then + while IFS= read -r run_id; do + [[ -z "$run_id" ]] && continue + echo "::warning::CI run ${run_id} for this release PR is awaiting workflow approval: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${run_id}" + gh api --method POST \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/approve" \ + >/dev/null 2>&1 \ + && echo "Approved workflow run ${run_id}; its checks can now report." \ + || echo "Could not approve run ${run_id} with this token; a maintainer must approve it once." + done <<< "$pending_runs" + awaiting_approval=1 + fi + fi + + 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." + 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." + fi + exit 1 + fi + sleep 20 + done + rm -f "$merge_err" "$pr_view_err" + + if [[ -z "$merged_sha" || "$merged_sha" == "null" ]]; then + echo "::error::GitHub did not return the merged main SHA for release PR #${pr_number}." + exit 1 + fi + + if git ls-remote --exit-code --heads origin "refs/heads/${release_branch}" > /dev/null 2>&1; then + git push origin --delete "$release_branch" + fi + { + echo "merged_sha=$merged_sha" + echo "pr_number=$pr_number" + } >> "$GITHUB_OUTPUT" + echo "Merged release metadata PR #${pr_number} at ${merged_sha}." >> "$GITHUB_STEP_SUMMARY" + + # 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 + # repository state, and fail safely before publication if main moved. + - name: Verify merged release + if: steps.decide.outputs.should_release == 'true' + shell: bash + env: + MERGED_SHA: ${{ steps.release_pr.outputs.merged_sha }} + NPM_VERSION: ${{ steps.decide.outputs.npm_version }} + run: | + set -euo pipefail + git fetch origin main --force + git checkout --detach origin/main + actual_sha="$(git rev-parse HEAD)" + if [[ "$actual_sha" != "$MERGED_SHA" ]]; then + echo "::error::main advanced from merged release ${MERGED_SHA} to ${actual_sha} before publication. Retry to rebuild release metadata on the new head." + exit 1 + fi + + actual_version="$(npm pkg get version | tr -d '"')" + if [[ "$actual_version" != "$NPM_VERSION" ]]; then + echo "::error::Merged package version ${actual_version} does not match intended release ${NPM_VERSION}." + exit 1 + fi + + npm ci + npm run release:check + diff_paths=(package.json package-lock.json manifest.json CHANGELOG.md) + [[ -f index.ts ]] && diff_paths+=(index.ts) + [[ -d src ]] && diff_paths+=(src) + git diff --exit-code -- "${diff_paths[@]}" + # `git diff` above compares dist against itself - nothing + # rebuilds before this point, so it passes unconditionally and + # cannot see untracked or ignored artifacts. Rebuild from clean + # so a stale committed dist cannot ship. + if git ls-files --error-unmatch 'dist' > /dev/null 2>&1; then + rm -rf dist + npm run build + dist_status="$(git status --porcelain=v1 --untracked-files=all --ignored=matching -- 'dist/')" + if [[ -n "$dist_status" ]]; then + echo "::error::Merged dist/ does not match a clean rebuild:" + echo "$dist_status" + exit 1 + fi + fi + - name: Publish npm package if: steps.decide.outputs.should_release == 'true' shell: bash @@ -155,6 +424,11 @@ jobs: run: | set -euo pipefail pkg_name="$(node -p "require('./package.json').name")" + # Idempotence guard: if the version already resolves on the registry, + # treat the publish as already done and exit 0. This is what lets an + # 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." exit 0 @@ -193,18 +467,50 @@ jobs: echo "::error::Publish failed after retries and provenance fallback." exit 1 - # Push tag + main AFTER a successful publish so a failed publish does not - # leave the repo tagged/skipped. The release ref (fast-forward of - # origin/main) was already verified in the "Check release ref" step before - # publishing, so here we only create the tag and advance main. - - name: Push release tag and main + # Tag the exact merged/verified main commit AFTER a successful publish. + # main is already advanced by the protected-PR merge above, so this step + # only creates and pushes the tag - it never pushes HEAD:main again (that + # push was what branch protection rejected with GH006, killing the job + # before the tag push in the old ordering). If publication fails, main + # retains the prepared metadata and the next run resumes the same version + # instead of inventing another release. + - name: Push release tag if: steps.decide.outputs.should_release == 'true' shell: bash + env: + RELEASE_TAG: ${{ steps.decide.outputs.tag }} run: | set -euo pipefail - git tag "${{ steps.decide.outputs.tag }}" - git push origin HEAD:main - git push origin "${{ steps.decide.outputs.tag }}" + release_tag="$RELEASE_TAG" + # Consult the remote, not just the local tag database. The last tag + # fetch happened back in `Decide release`; if anything created this + # tag on origin since then, the local lookup misses it, `git tag` + # succeeds locally and the push below is rejected as non-fast-forward + # - after a successful publish, which is the npm-ahead-of-git state + # this workflow exists to prevent. + git fetch origin --force --tags + remote_tag_sha="$(git ls-remote --refs --tags origin "refs/tags/${release_tag}" | awk 'NR == 1 { print $1 }')" + current_sha="$(git rev-parse HEAD)" + if [[ -n "$remote_tag_sha" ]]; then + remote_commit="$(git rev-list -n 1 "$remote_tag_sha")" + if [[ "$remote_commit" != "$current_sha" ]]; then + echo "::error::${release_tag} already exists on origin at ${remote_commit}, not verified main ${current_sha}." + exit 1 + fi + echo "::notice::${release_tag} is already on origin at the verified commit; nothing to push." + exit 0 + fi + if git rev-parse --verify --quiet "refs/tags/${release_tag}" > /dev/null; then + existing_sha="$(git rev-list -n 1 "$release_tag")" + current_sha="$(git rev-parse HEAD)" + if [[ "$existing_sha" != "$current_sha" ]]; then + echo "::error::${release_tag} already points to ${existing_sha}, not verified main ${current_sha}." + exit 1 + fi + else + git tag "$release_tag" + fi + git push origin "refs/tags/${release_tag}" - name: Verify bun install of published package if: steps.decide.outputs.should_release == 'true'