Skip to content

Fix release publish ordering ahead of protected main push - #33

Merged
unbraind merged 8 commits into
mainfrom
fix-release-publish-before-protected-main-push
Aug 10, 2026
Merged

Fix release publish ordering ahead of protected main push#33
unbraind merged 8 commits into
mainfrom
fix-release-publish-before-protected-main-push

Conversation

@unbraind

@unbraind unbraind commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Problem

The daily release workflow ran npm publish and then git push origin HEAD:main. Because main is protected (required checks test (22) and test (26), enforce_admins: true, required_conversation_resolution: true), that push was rejected with GH006, and the job's fail-fast shell mode killed it before the tag push. The result: npm held the new version while main stayed on an older version with no matching tag — every daily release failed.

Fix

Ported the verified ordering fix from pm-beads#65:

  1. Added pull-requests: write permission.
  2. Captured base_sha in the Decide release step.
  3. Replaced the publish-then-push steps with:
    • Merge release metadata through protected PR — merges the release commit into main via a protected PR before any publish, using the same merge loop (resolve review threads, attempt the merge API, fail fast only on DIRTY/BEHIND, 30-minute deadline).
    • Verify merged release — re-checks the merged main commit byte-for-byte before publication.
    • Publish npm package — keeps the idempotence guard so an already-published version reconciles instead of failing with a 403.
    • Push release tag — tag only; git push origin HEAD:main no longer appears.
  4. The verify diff file list names the paths this repo actually commits. dist/ is gitignored here, so it is excluded from the verify diff.

The merge loop is byte-identical to pm-beads (only the PR title and the verify diff path list differ per repo). All existing repo-specific details (build steps, RELEASE_TIMEZONE, changelog generation, commit pathspec, GitHub release title) are preserved.

Tracking

pm item: https://github.com/unbraind/pm-github/blob/main/.agents/pm/issues/pm-github-v2kt.toon

Summary by Sourcery

Gate daily releases through a protected PR that merges release metadata into main before publication, ensuring branch protection is respected and main and npm stay in sync.

Enhancements:

  • Capture the base main SHA in the release decision step and use it to detect main advancing during release preparation.
  • Introduce a merge-and-verify flow that creates a temporary release branch, merges it via the GitHub merge API under branch protection, and byte-checks the merged main state before publishing.
  • Make the publish step idempotent by treating already-published versions as successful runs and reconciling workflow state.
  • Change the release tagging step to only create and push tags for the verified merged main commit, dropping direct pushes to main.
  • Add project management tracking artifacts for this release workflow change.

CI:

  • Grant pull request write permissions and expand the release workflow to manage and merge a protected release PR as part of the automated daily release.

Summary by cubic

Fixes the daily release workflow by merging the release commit through a protected PR before npm publish, then pushing only the release tag. Prevents GH006 rejections and keeps main, tags, and npm in sync.

  • Bug Fixes
    • Merge the release commit into main via a protected PR before npm publish; removed git push HEAD:main.
    • Verify the merged main commit and package version before publishing; if dist/ is gitignored, exclude it; if tracked, rebuild from clean and check with git status including untracked and ignored files so hidden artifacts can’t slip in.
    • Keep publish idempotent (skip if version already exists) and push only the release tag; fetch remote tags first (using --refs), compare targets, and exit cleanly if the tag already points to the verified commit.
    • Grant pull-requests: write and actions: write; capture base_sha; merge loop fails fast on DIRTY/BEHIND, resolves only threads where every comment is bot-authored (skips empty and any truncated/paginated threads), detects CI runs parked on action_required (logs URL, attempts approval, now scans 100 runs per page), and distinguishes awaiting-approval from failed checks.

Written for commit cbfb808. Summary will update on new commits.

Review in cubic

The daily release workflow ran npm publish 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 while npm already held the new
version.

Merge the release metadata through a protected PR before publishing so
main already contains the release commit when npm publish runs. After a
successful publish, push only the release tag. The npm publish step
keeps its idempotence guard so an already-published version (e.g.
2026.8.10, which landed on npm while main was still behind) reconciles
instead of failing with a 403.

Ported from the verified fix in pm-beads#65.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @unbraind, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

Summary by CodeRabbit

  • Bug Fixes

    • Improved release ordering by publishing packages from verified, merged release metadata.
    • Added safeguards for conflicts, incomplete checks, unexpected branch changes, and approval or review-handling issues.
    • Improved validation of generated release files and remote tags.
    • Prevented duplicate or mismatched releases when versions or tags already exist.
  • Reliability

    • Releases now require merge confirmation and post-merge verification before publication.
    • Tags are created only after successful publication on the verified commit.

Walkthrough

Changes

Protected release workflow

Layer / File(s) Summary
Release baseline and permissions
.github/workflows/release.yml
The workflow grants pull-request and actions write permissions and records the release base commit SHA.
Protected pull-request orchestration
.github/workflows/release.yml
Release metadata moves through a protected pull request. The workflow validates branch state, review threads, required checks, merge completion, merged SHA, package version, and reproducible dist output.
Publication, tagging, and release records
.github/workflows/release.yml, .agents/pm/issues/pm-github-v2kt.toon, .agents/pm/history/pm-github-v2kt.jsonl
Publishing handles already-published versions as successful no-ops. Tagging targets the verified merged commit and rejects mismatched existing tags. The issue and history records document the workflow changes.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Actions as GitHub Actions
  participant API as GitHub API
  participant PR as Release pull request
  participant Main as main branch
  participant NPM as npm registry
  Actions->>API: Create or update release pull request
  API->>PR: Validate reviews and required checks
  Actions->>API: Request protected merge
  API->>Main: Merge release metadata
  Actions->>API: Verify merged SHA and dist output
  Actions->>NPM: Publish package version
  Actions->>API: Push release tag to verified merged commit
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely summarizes the release ordering fix for protected main.
Description check ✅ Passed The description directly explains the protected-branch release workflow changes and their purpose.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-release-publish-before-protected-main-push

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sourcery-ai

sourcery-ai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Reviewer's Guide

Reworks the daily release workflow to merge release metadata through a protected PR before publishing, verifies the merged main commit, makes npm publish idempotent, and replaces the push of main with a tag-only push while adding repo-specific verify paths and new PM tracking files.

Sequence diagram for protected PR merge and publish ordering in daily release workflow

sequenceDiagram
    participant Workflow as daily_release_job
    participant GitHub as github_repo
    participant GH_API as github_api
    participant npm as npm_registry

    Workflow->>GitHub: git fetch origin main
    Workflow->>GitHub: capture base_sha (Decide release)

    rect rgb(235, 245, 255)
    Workflow->>GitHub: git push HEAD:refs/heads/release/<tag>
    Workflow->>GH_API: gh pr list/create (Release pm-github <RELEASE_TAG>)
    Workflow->>GH_API: gh pr view (mergeStateStatus, statusCheckRollup)
    Workflow->>GH_API: gh api graphql resolveReviewThread
    Workflow->>GH_API: gh api PUT pulls/<pr_number>/merge (merge_method=rebase)
    GH_API-->>Workflow: merged_sha
    Workflow->>GitHub: git push origin --delete release/<tag>
    end

    rect rgb(235, 255, 235)
    Workflow->>GitHub: git fetch origin main
    Workflow->>GitHub: git checkout --detach origin/main
    Workflow->>GitHub: verify HEAD == merged_sha
    Workflow->>GitHub: verify npm pkg version == NPM_VERSION
    Workflow->>GitHub: npm ci
    Workflow->>GitHub: npm run release:check
    Workflow->>GitHub: git diff selected paths
    end

    rect rgb(255, 245, 225)
    Workflow->>npm: npm view pkg@NPM_VERSION
    alt version already published
        npm-->>Workflow: version exists
        Workflow-->>npm: skip npm publish
    else version not published
        npm-->>Workflow: not found
        Workflow->>npm: npm publish (with retries/provenance)
    end
    end

    rect rgb(255, 235, 235)
    Workflow->>GitHub: git tag <RELEASE_TAG> (if absent)
    Workflow->>GitHub: git push origin refs/tags/<RELEASE_TAG>
    end
Loading

File-Level Changes

Change Details Files
Restructure release workflow to merge via protected PR before publishing, capture base SHA, and verify merged main prior to npm publish.
  • Add pull-requests: write permission to the workflow
  • Capture base_sha in the Decide release step for later consistency checks
  • Introduce a Merge release metadata through protected PR step that creates/updates a temporary release branch, opens or reuses a PR to main, and loops until GitHub’s merge API successfully merges under branch protection
  • Handle advanced main, DIRTY/BEHIND PR states, and unresolved review threads as fatal or auto-resolved conditions in the merge loop
  • Output merged_sha and pr_number for downstream steps
  • Add a Verify merged release step that re-fetches origin/main, confirms the merged SHA and package version, reruns release checks, and diff-checks only relevant paths for this repo
.github/workflows/release.yml
Harden publish and tag steps to be idempotent and tag-only, avoiding direct pushes to main while keeping tags consistent with the verified commit.
  • Keep npm publish idempotence guard by skipping when the version already exists in the registry
  • Change the final step from pushing main+tag to only creating and pushing the release tag against the verified HEAD
  • Add safety checks so an existing tag must already point at the verified main commit or the job fails
.github/workflows/release.yml
Add PM tracking artifacts for this release automation change.
  • Introduce JSONL history file for the pm-github item
  • Add corresponding .toon issue file for tracking in the pm system
.agents/pm/history/pm-github-v2kt.jsonl
.agents/pm/issues/pm-github-v2kt.toon

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

…roval

A run GitHub parks on action_required never appears in statusCheckRollup, so
the merge wait could not tell it apart from a required check that failed and
logged 'settling: none' until the deadline.

The wait now surfaces any parked run with its URL, attempts approval via the
actions write permission, and reports awaiting-approval separately from a
failed check.
@unbraind

Copy link
Copy Markdown
Owner Author

@greptileai Thanks — the risk you flagged is real and it is the one that actually bit us in production, but the mechanism is not event suppression. I measured it, so here is the evidence.

The pull_request event is not suppressed. pm-changelog's release PR #133 was created by github-actions[bot] with GITHUB_TOKEN on branch release/2026.8.9, and GitHub created a CI run for it immediately:

$ gh api repos/unbraind/pm-changelog/actions/runs/31356945307
name=CI  event=pull_request  actor=github-actions[bot]
created=2026-08-10T04:54:24Z  branch=release/2026.8.9

created_at is the same second the release job pushed the branch. A suppressed event produces no run at all.

The real blocker is workflow approval. Attempt 1 never executed:

$ gh api .../actions/runs/31356945307/attempts/1
status=completed  conclusion=action_required
$ gh api repos/unbraind/pm-changelog/actions/permissions/fork-pr-contributor-approval
{"approval_policy":"first_time_contributors"}

github-actions[bot] had no merged PR in that repo, so its first PR was gated. test (22)/test (26) only ran as attempt 2 at 05:36:11Z with triggering_actor=unbraind — a human re-ran it, 12 minutes after the release job had already timed out at 05:24Z.

Why it looked like "no checks". A parked run never appears in statusCheckRollup, so the wait logged settling: none; failing: none while mergeStateStatus stayed BLOCKED — indistinguishable from a required check that failed. That is exactly why this change stops inferring from mergeStateStatus and instead attempts the merge, letting the merge API be the authority: a refused merge is harmless because nothing has been published at that point.

What I changed in response to your finding. The merge wait now detects runs parked on action_required for the release head SHA, prints each run's URL, attempts POST /actions/runs/<id>/approve (with actions: write — best-effort, the token may be refused), and the deadline error distinguishes "awaiting workflow approval" from "a required check failed". So the first automated release PR in a repository reports its own blocker immediately instead of spending 30 minutes looking like a broken test.

Net safety. This is strictly better than the ordering it replaces either way. Today's code publishes to npm and then fails to push the version bump to protected main, which is why npm currently holds 2026.8.10 across the fleet while main sits on 2026.8.7/2026.8.8 with no matching tag. Under the new ordering the worst case is a release that does not publish — recoverable next run, and the idempotence guard reconciles the already-published version instead of failing on a 403.

Recording this as valid-with-corrected-mechanism rather than dismissing it.

@unbraind

Copy link
Copy Markdown
Owner Author

@greptileai

@unbraind

Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 57 minutes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/release.yml:
- Line 11: Remove pull-requests: write from the workflow-level permissions and
add it to the job-level permissions block for the release job. Keep the
workflow-level token permissions minimal while preserving the release job’s
required access.
- Around line 416-434: Update the “Push release tag” step before its local
existence check to refresh remote tag refs from origin, preserving tags needed
for accurate conflict detection. Ensure the subsequent refs/tags/${release_tag}
check sees tags created by other runs and prevents npm publication from being
followed by a rejected push.
- Around line 284-307: Update the release-PR merge retry loop to exit
immediately when the computed failing check collection is non-empty, before
attempting another merge or sleeping. Use the existing failing symbol and
preserve the current error reporting and non-zero exit behavior, while leaving
retries unchanged when no checks have failed.
- Around line 261-278: Update the GraphQL query in the release workflow’s
unresolved-thread collection to include each thread’s first comment author
login, and filter results to unresolved threads authored by the known advisory
bots Sourcery, cubic, or CodeRabbit. Keep the resolveReviewThread mutation
unchanged, ensuring human-authored threads are excluded from auto-resolution.
- Around line 151-207: Update the release_pr step to authenticate branch pushes
and PR creation with a repository credential capable of triggering release CI,
replacing the default github.token in GH_TOKEN with the configured GitHub App
installation token or PAT secret. Preserve the existing release branch and PR
logic, and ensure the selected credential is available to both git push and gh
commands before relying on protected main checks for merging.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 84bbbe31-535a-402e-a81b-a9f70639832a

📥 Commits

Reviewing files that changed from the base of the PR and between 28180cf and bfba224.

📒 Files selected for processing (3)
  • .agents/pm/history/pm-github-v2kt.jsonl
  • .agents/pm/issues/pm-github-v2kt.toon
  • .github/workflows/release.yml

Comment thread .github/workflows/release.yml
Comment thread .github/workflows/release.yml
Comment thread .github/workflows/release.yml
Comment thread .github/workflows/release.yml
Comment thread .github/workflows/release.yml
@greptile-apps

greptile-apps Bot commented Aug 10, 2026

Copy link
Copy Markdown

Correction accepted — my original mechanism was wrong. Event suppression by GITHUB_TOKEN is accurately described only for push/release events; the pull_request event fires regardless of token, and your measurement from pm-changelog's run confirms it.

The fix correctly targets the actual blocker. A conclusion=action_required run is invisible to statusCheckRollup, so the merge loop's prior behaviour was indistinguishable from "no required checks have started yet" — the loop just burned the 30-minute deadline with no signal. Surfacing the run URL and attempting POST .../approve is the right response: it gives a maintainer an actionable link on first occurrence rather than a cryptic timeout.

One minor edge case: awaiting_approval is set to 1 when a parked run is detected but is never reset after a successful approve call. If the approval succeeds and checks start running, a subsequent timeout (e.g. a slow test suite that then fails) will still emit the "awaiting workflow approval" deadline message rather than the "required check failed" one. It won't cause incorrect behaviour — the run URL was already printed — but it could misdirect a maintainer who sees the deadline error in isolation. You could reset it:

              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}"
                  if gh api --method POST \
                    "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/approve" \
                    >/dev/null 2>&1; then
                    echo "Approved workflow run ${run_id}; its checks can now report."
                    awaiting_approval=0
                  else
                    echo "Could not approve run ${run_id} with this token; a maintainer must approve it once."
                    awaiting_approval=1
                  fi
                done <<< "$pending_runs"
              fi

That way awaiting_approval reflects the current state: still stuck waiting → 1, successfully kicked off → 0 (and the loop continues waiting for the checks to pass or fail normally).

The ordering fix and the overall approach are sound.

@greptile-apps

greptile-apps Bot commented Aug 10, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes the daily release workflow's GH006 failure by routing the release commit through a protected PR before npm publish, so branch protection is satisfied before any publication side effects occur. The old git push HEAD:main (which was rejected by branch protection) is replaced entirely with a merge-API loop that treats the GitHub response as the authority, followed by a byte-level SHA verification step and a tag-only push after publish.

  • Protected-PR merge loop — pushes the release commit to a temporary branch, creates or reuses a PR, resolves bot-only review threads (with totalCount pagination guard to preserve human replies), detects CI runs parked on action_required and attempts approval, and fails fast only on DIRTY/BEHIND states. The 30-minute deadline surfaces a clear diagnostic error distinguishing awaiting-approval from failed checks.
  • Verify merged release — fetches and checks out origin/main after the merge, compares the SHA byte-for-byte against the API-returned merged_sha, re-runs npm ci and release:check, and verifies tracked release files via git diff --exit-code; for repos that track dist/, a clean rebuild and git status --ignored=matching confirm the compiled artifact matches before publication.
  • Idempotent publish and tag-only push — the publish step skips gracefully if the version is already on the registry; the tag step fetches remote tags before creation to prevent a non-fast-forward push after a successful publish, which was the original failure mode this PR eliminates.

Confidence Score: 5/5

  • The change is safe to merge. The core ordering fix — merge via protected PR before publish, then tag only — correctly eliminates the GH006 failure path and leaves no window where npm can advance ahead of git.
  • All three changed files are either tracking artifacts or the workflow itself. The merge loop, SHA verification, idempotent publish guard, and remote-tag check each cover the specific failure modes documented in the PR history. Edge cases (resume after partial run, parked CI runs, DIRTY/BEHIND states, annotated tag SHAs, truncated review thread pagination) have all been handled with defensive checks. No issues were found in the changed files that would cause incorrect behavior.
  • No files require special attention.

Important Files Changed

Filename Overview
.github/workflows/release.yml Replaces the broken publish-then-push-to-main ordering with a protected-PR merge loop, byte-level SHA verification, idempotent publish, and tag-only push. The new steps are well-guarded: DIRTY/BEHIND states fail fast, bot-only review threads are resolved (with totalCount pagination safeguard), action_required parked runs are surfaced and approval attempted, and the remote tag is consulted before creation to prevent non-fast-forward push failures after a successful publish.
.agents/pm/history/pm-github-v2kt.jsonl New PM tracking history file for the release-ordering issue. Contains agent and review notes documenting the progression of fixes across review rounds. No code concerns.
.agents/pm/issues/pm-github-v2kt.toon New PM issue tracking file summarising the release publish-ordering bug and its fix. Metadata only; no code concerns.

Sequence Diagram

sequenceDiagram
    participant W as Workflow Runner
    participant G as GitHub API
    participant O as origin/main
    participant N as npm Registry

    W->>W: "Decide release (capture base_sha)"
    W->>W: "Update release version + build"
    W->>W: "Generate changelog, commit release files"
    W->>W: "Check release ref (must be main)"

    W->>O: "git push HEAD to release/YYYY.MM.DD branch"
    W->>G: "gh pr create (or reuse existing PR)"

    loop "Every 20s up to 30 min"
        W->>G: "Resolve bot-only review threads (GraphQL)"
        W->>G: "PUT /pulls/{pr}/merge (rebase)"
        G-->>W: "merged_sha OR 405/409"
        alt "merge_state DIRTY/BEHIND"
            W->>W: "exit 1 (fail fast)"
        else "run parked on action_required"
            W->>G: "POST /actions/runs/{id}/approve"
        end
    end

    W->>O: "git push --delete release/branch"
    W->>O: "git fetch + checkout --detach origin/main"
    W->>W: "Verify SHA == merged_sha, npm version, git diff"
    W->>N: "npm publish (idempotent guard)"
    N-->>W: "published or already exists"
    W->>O: "git fetch --tags, compare remote tag"
    W->>O: "git push refs/tags/vYYYY.MM.DD"
    W->>G: "gh release create"
Loading

Reviews (17): Last reviewed commit: "fix(release): request 100 workflow runs ..." | Re-trigger Greptile

@unbraind

Copy link
Copy Markdown
Owner Author

Review round 2 — every finding triaged, with what changed.

✅ Fixed: unterminated gh pr create command substitution (CodeRabbit, Critical)

This was a real defect and a good catch. The closing ) sat inside the quoted --body value:

--body "... until this protected PR is merged.)"      # ends .)"  — WRONG

so pr_url="$(gh pr create ... was never closed. Bash consumed the following lines and died with syntax error near unexpected token 'fi'. That would have broken release PR creation — the common path — on the very first run.

Worth stating why nothing else caught it: a run: block is just a string to YAML, so a YAML parse cannot see bash errors, and no gate executes the workflow. npm ci, npm run build, npm test and npm run release:check all passed on the broken file.

So I added the gate that can catch it — every bash run: block is extracted (with ${{ }} expressions neutralised) and checked with bash -n:

checked 13 run blocks, 0 failed

and I confirmed it is not vacuous by reverting the fix and re-running:

FAIL release / Merge release metadata through protected PR: syntax error near unexpected token `fi'
checked 13 run blocks, 1 failed   (exit 1)

Swept across all 21 fleet repositories: 6 were affected (pm-presets, pm-slack, pm-slack-standup, pm-starter, pm-todos, pm-ts-starter), all now fixed; the other 15 already terminated correctly. The body now also carries the tracking-item link, so it resolves after merge.

❌ Not reproducible: next-day retry version mismatch (Greptile, P1)

I traced this rather than assuming it. The scenario — "metadata merged on day 1, publish resumes day 2, Verify merged release fails" — cannot occur, because Verify merged release compares the merged package.json against the same run's decide output:

NPM_VERSION: ${{ steps.decide.outputs.npm_version }}
actual_version="$(npm pkg get version | tr -d '"')"

On a day-2 run, decide derives 2026.8.11, the release PR it builds carries 2026.8.11, and the comparison is 2026.8.11 vs 2026.8.11. There is no path where a day-2 run validates day-1 metadata.

The stale-metadata resume branch fires only when release_commit == current_main_sha — i.e. when the version bump produced no new commit because main already carries exactly that version. That requires decide to compute the same version, which means the same calendar day. Cross-day resume is therefore unreachable.

The one real (benign) consequence: if day 1 merges metadata for 2026.8.10 and never publishes, day 2 rolls forward to 2026.8.11 and 2026.8.10 is simply skipped on npm. Git stays internally consistent — no tag without a publish, no publish without a tag. Rolling forward is the safer behaviour here, so I am deliberately not adding resume-the-older-version logic: it would reintroduce exactly the divergence this PR exists to remove.

❌ Declined with reason: scope pull-requests: write to the job (CodeRabbit, Trivial)

Correct as general least-privilege advice, and I agree it would matter with a second job. These workflows have exactly one job (release), so workflow-level and job-level grants are identical in effect today, and that single job genuinely needs contents: write, pull-requests: write, id-token: write and actions: write. Moving the block changes no token surface while costing another 21-repository round-trip during an active release outage. Recorded in the tracking item so it is a deliberate decision rather than an oversight.


Context for reviewers: main currently holds 2026.8.7/2026.8.8 in thirteen of these repositories while npm already has 2026.8.10, because the previous ordering published first and then had its push to protected main rejected. The idempotent publish guard makes this PR self-reconciling — a re-run recomputes 2026.8.10, skips the already-published npm step, and pushes the missing tag.

Push release tag consulted only the local tag database while the last tag
fetch happens in Decide release. A tag created on origin in between would be
missed: git tag succeeds locally and the push is rejected as non-fast-forward,
after a successful publish - the npm-ahead-of-git state this work removes.
It now fetches tags and compares against the remote tag target.

The dist entry in the verification diff verified nothing: nothing rebuilds
before it, so git diff compared dist against itself and always passed, and it
could not see untracked or orphaned artifacts. Where dist is tracked the step
now rebuilds from clean and uses git status --porcelain --untracked-files=all.
Reproducibility was confirmed locally in every repo that tracks dist.
@unbraind

Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@unbraind

Copy link
Copy Markdown
Owner Author

@greptileai

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.


Your included review limit is currently reached under our Fair Usage Limits Policy. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 32 minutes.

Comment thread .github/workflows/release.yml
Resolving every unresolved thread would also clear a human reviewer's
blocking comment, removing the protection required_conversation_resolution
exists to give release commits.

The GraphQL query now returns each thread's first-comment author type and
only threads authored by a Bot are resolved. The jq filter was checked
against a payload holding one bot thread and one human thread; it returns
only the bot thread.
@unbraind

Copy link
Copy Markdown
Owner Author

Review round 3.

✅ Fixed: thread resolution was too broad (CodeRabbit, Major / Security)

This one was a regression I introduced, and the reasoning is exactly right: resolving every unresolved thread would also clear a human reviewer's blocking comment, which removes the protection required_conversation_resolution exists to give release commits. Auto-resolution is only defensible for advisory bot threads.

The GraphQL query now returns each thread's first-comment author type, and only Bot threads are resolved:

.data.repository.pullRequest.reviewThreads.nodes[]?
| select(.isResolved==false)
| select(.comments.nodes[0].author.__typename=="Bot")
| .id

Verified against a payload holding one bot thread and one human thread rather than assumed — it returns only the bot thread:

$ ... | jq -r '<filter above>'
BOT1

Applied to all 21 fleet repositories.

⚠️ Acknowledged as a real limitation, deliberately not "fixed" (Greptile, P1 — parked CI remains blocked)

Accurate description: if GitHub parks the release PR's CI on action_required and refuses approval through this workflow's GITHUB_TOKEN, the run only logs that a maintainer must intervene, and the release exits after the deadline without publishing or tagging.

I am keeping that behaviour, for three reasons.

1. There is no sound automated path to start it. The parked run is gated precisely because GitHub decided this actor may not run workflows unattended. A workflow_dispatch re-run would execute under a different event context, and the required contexts (test (22), test (26)) are matched by name against the PR's head SHA in a pull_request context. Manufacturing a substitute run risks satisfying a name without satisfying the check the protection intends — which is the class of "gate that looks like it worked" this whole change exists to eliminate.

2. The failure is the safe direction. Exiting without publishing leaves npm and git consistent and the release retryable. The alternative this PR replaces did the opposite: it published to npm and then failed to push the version bump, which is why main currently sits on 2026.8.7/2026.8.8 in thirteen repositories while npm already holds 2026.8.10. A release that does not happen costs a day; a release that publishes without git costs manual reconciliation.

3. It is one-time per repository, not recurring. The gate is approval_policy: first_time_contributors, and it applies because github-actions[bot] has no merged PR in the repo yet. Once the first release PR merges, the actor is no longer a first-time contributor and subsequent release PRs run CI normally. So the cost is a single approval per repository, once — which is exactly what this branch now makes visible, with the run URL in a ::warning:: and a deadline error that says "awaiting workflow approval" instead of "a required check failed".

Before this change that same situation produced 30 minutes of settling: none; failing: none and a message blaming a failed check — which is how it went undiagnosed in pm-changelog for four consecutive days.

Down-voting nothing here: the finding is correct and worth having on the record as a known operational cost rather than silently accepted.

@unbraind

Copy link
Copy Markdown
Owner Author

@greptileai

@unbraind

Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Rate Limit Exceeded

@unbraind have exceeded the limit for the number of chat messages per hour. Please wait 39 minutes and 3 seconds before sending another message.

Classifying a thread by its first comment let a bot-opened thread with a
substantive human reply be auto-resolved, clearing the human concern and
letting the release merge without it being addressed. A thread now counts as
advisory only when every comment on it is bot-authored; threads with no
comments are excluded.

Verified against four thread shapes (all-bot, bot-then-human, human-only,
empty): only the all-bot thread is selected.

The dist rebuild check also passes --ignored=matching so newly generated
ignored artifacts under dist/ cannot hide from git status.
@unbraind

Copy link
Copy Markdown
Owner Author

Review round 4 — both findings were valid and are fixed.

✅ Mixed-author threads bypass review (Greptile, P1)

This is the better of the two catches, and it is a defect in my previous fix rather than in the original code. Round 3 narrowed thread resolution to bot threads by reading comments(first:1) — which classifies an entire thread from whoever opened it. A review bot opens a thread, a human reviewer adds a substantive unresolved reply, the thread is still labelled Bot, and the release resolves it. The human concern is cleared and the merge proceeds. That is precisely the hole the round-3 fix was meant to close, moved one level down.

A thread now counts as advisory only when every comment on it is bot-authored, and a thread with no comments is excluded rather than defaulting to resolvable:

.data.repository.pullRequest.reviewThreads.nodes[]?
| select(.isResolved==false)
| select([.comments.nodes[]?.author.__typename]
         | length > 0 and all(. == "Bot"))
| .id

The query now fetches comments(first:100) instead of first:1.

Verified against four thread shapes rather than reasoned about — all-bot, bot-then-human, human-only, and empty:

$ ... | jq -r '<filter above>'
ALLBOT

Only the all-bot thread is selected; the mixed thread your finding describes is left blocking.

✅ Ignored generated files in dist/ (CodeRabbit, Major)

Correct. git status --untracked-files=all still hides files matched by .gitignore, so a newly generated ignored artifact under dist/ could survive the rebuild comparison and ship. Now --ignored=matching, keeping the trailing dist/ pathspec.

A note on how this round was validated

The first attempt at this patch broke the YAML in 14 repositories — the rewritten comment block was inserted without its leading indentation, which terminates a run: block scalar. The bash -n run-block gate added in round 2 caught it before anything was committed, and the patcher now derives indentation from the matched line instead of hard-coding it. Same failure mode as an earlier mis-indent in this series, now with a gate in front of it.

All 21 fleet repositories re-verified after the fix: YAML parses, every bash run: block passes bash -n, and the all-bot filter is present exactly once in each.

@unbraind

Copy link
Copy Markdown
Owner Author

@greptileai

…tion

Seven repositories track dist but had no rebuild check: the earlier patch
keyed on a diff_paths idiom they do not use, so their verification still
compared dist against itself. They now rebuild from clean and compare with
git status including untracked and ignored files. Reproducibility was
confirmed locally in each repository before the check was enabled.

Thread resolution fetched only the first 100 comments, so a human reply past
that page was invisible to the all-bot predicate. Any thread whose comment
total exceeds the comments returned is now left unresolved.
@unbraind

Copy link
Copy Markdown
Owner Author

Review round 5 — both findings valid, both fixed, and one of them exposed a wider gap.

✅ The tracking note described dist validation the workflow did not have (CodeRabbit, Major)

You were right, and checking my claim against the code is exactly the right thing to have done — the history record over-promised.

The cause: the round-2 patch keyed on a diff_paths+=(dist) idiom, and several repositories build their verification differently, so the check silently never landed there while the note said it had. Auditing every repository for "tracks dist" versus "has the rebuild check" found seven in that state, not just this one:

pm-changelog  dist=tracked  check=0   <== GAP
pm-context    dist=tracked  check=0   <== GAP
pm-gantt-chart dist=tracked check=0   <== GAP
pm-graph      dist=tracked  check=0   <== GAP
pm-jira       dist=tracked  check=0   <== GAP
pm-ops        dist=tracked  check=0   <== GAP
pm-web        dist=tracked  check=0   <== GAP

All seven now rebuild dist from clean and compare with git status --porcelain=v1 --untracked-files=all --ignored=matching -- 'dist/'. Reproducibility was confirmed locally in each one before the check was enabled, so this cannot turn into a flaky release:

pm-changelog: REPRODUCIBLE
pm-context: REPRODUCIBLE
pm-gantt-chart: REPRODUCIBLE
pm-graph: REPRODUCIBLE
pm-jira: REPRODUCIBLE
pm-ops: REPRODUCIBLE
pm-web: REPRODUCIBLE

The tracking note has been corrected to say what the code actually does, in every affected repository.

✅ Thread comment pagination bypasses review (Greptile, P1)

Also correct. comments(first:100) returns a page, not the thread, so a human reply past that page is invisible to the all-bot predicate and the thread would be resolved.

Rather than paginate (which still has to terminate somewhere), the filter now refuses to judge a thread it has not fully seen — if the comment total exceeds the nodes returned, the thread is left unresolved and keeps blocking:

| select(.comments.totalCount == ([.comments.nodes[]?] | length))
| select([.comments.nodes[]?.author.__typename] | length > 0 and all(. == "Bot"))

Verified against a truncated thread (totalCount: 150, two nodes fetched), an all-bot complete thread, and a mixed thread — only the complete all-bot thread is selected:

ALLBOT_COMPLETE

Failing closed is the right default here: the cost of not resolving a bot thread is one manual click; the cost of resolving a human's thread is a release that ships past an unaddressed review.


All 21 repositories re-verified after this round: YAML parses, every bash run: block passes bash -n, the pagination guard and the dist check are each present. pm-vcs is on its sixth self-host bundle refresh, since every tracked change re-diverges its committed bundle.

@unbraind

Copy link
Copy Markdown
Owner Author

@greptileai

…leak in

git ls-remote --tags emits the peeled ^{} entry alongside an annotated tag,
which would put two SHAs in one variable. Verified that this only happens
with no pattern or a glob - the exact refspec used here returns a single
line, so it did not reproduce - but --refs with an explicit first-line
select costs nothing and keeps the code correct if the pattern is ever
loosened.
@unbraind

Copy link
Copy Markdown
Owner Author

Review round 6.

⚠️ Adopted, but it does not reproduce here (CodeRabbit — annotated remote tags)

The mechanism is real: git ls-remote --tags emits the peeled ^{} entry alongside an annotated tag, which would put two SHAs into remote_tag_sha and make git rev-list fail. I tested it against an actual annotated tag pushed to a bare remote rather than reasoning about it, and the peeled entry's appearance depends on how the ref is addressed:

$ git ls-remote --tags origin                        # no pattern
ba55993... refs/tags/vTEST
ce6dd0c... refs/tags/vTEST^{}                        # <-- peeled entry

$ git ls-remote --tags origin "vTEST*"               # glob
ba55993... refs/tags/vTEST
ce6dd0c... refs/tags/vTEST^{}                        # <-- peeled entry

$ git ls-remote --tags origin "refs/tags/vTEST"      # exact refspec (what this workflow uses)
ba55993... refs/tags/vTEST                           # single line

This workflow passes the exact refspec refs/tags/${release_tag}, so it gets one line and git rev-list succeeds:

OLD: ok
NEW: ok

So the reported failure does not occur against this call site. I adopted the suggestion anyway — --refs plus an explicit first-line select costs nothing, states the intent, and keeps the code correct if that pattern is ever loosened to a glob. Recorded in the tracking item as "adopted, did not reproduce" rather than "fixed a live bug", so the history stays accurate.

⚠️ Acknowledged, deliberately not paginated (thread-level pagination)

reviewThreads(first:100) does page, so on a pull request with more than 100 review threads the later pages are never fetched.

The important property is which way that fails: unfetched threads are simply never resolved, so they keep blocking the merge. The release then waits out its deadline and exits without publishing or tagging — the safe direction, and the same direction the rest of this change is built around. It cannot cause a thread to be cleared without being read.

Paginating would trade a bounded, fail-safe limitation for more moving parts in the one step that must not misbehave, on a scenario (100+ threads on an automated release PR that bots opened) that has no realistic path here. If it ever occurs the symptom is a release that does not happen, which is visible and recoverable, not a release that ships past an unread human comment.


State after this round: all 21 repositories re-verified — YAML parses, every bash run: block passes bash -n, the all-bot thread predicate, the comment-pagination guard, the dist rebuild check (where dist is tracked) and the remote-tag read are each present. pm-vcs is on its seventh self-host bundle refresh.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/release.yml:
- Around line 276-285: Paginate both release-PR API collections: in the
review-thread query used by the unresolved-thread handling block, request
pageInfo{hasNextPage endCursor} and loop with an after cursor until all
review-thread pages are fetched before applying the existing bot-only filters
and resolution logic; in the workflow-runs API call around lines 327-330, add
per_page=100 to gh api --paginate before filtering for action_required or
waiting runs. Update both affected sites in .github/workflows/release.yml: lines
276-285 for review-thread pagination and lines 327-330 for workflow-run
pagination.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 14b65f7d-740a-4a95-85ee-33fa5279592b

📥 Commits

Reviewing files that changed from the base of the PR and between bfba224 and 5bc0dad.

📒 Files selected for processing (3)
  • .agents/pm/history/pm-github-v2kt.jsonl
  • .agents/pm/issues/pm-github-v2kt.toon
  • .github/workflows/release.yml

Comment thread .github/workflows/release.yml
…d CI

The workflow-runs endpoint defaults to 30 results per page, so a run parked
on action_required past that page would not be detected or approved. The
detection query now asks for 100 per page.
@unbraind

Copy link
Copy Markdown
Owner Author

Round 6 follow-up — the last actionable item is addressed, and I am treating the review loop as converged here.

✅ Workflow-runs pagination (CodeRabbit)

Correct: GET /actions/runs defaults to 30 results per page, so a run parked on action_required past that page would not be detected or approved. The detection query now requests per_page=100.

Worth noting which direction this one fails: an undetected parked run means the release simply is not approved and times out without publishing or tagging — the diagnostic is lost, not the safety property. The larger page costs nothing, so it is in.

⚠️ Thread-level pagination — declined, restated

reviewThreads(first:100) does page. Unfetched threads are never resolved, so they keep blocking and the release exits without publishing. That is the safe direction, and it cannot clear a thread it has not read. Paginating would add moving parts to the one step that must not misbehave, to guard a case — 100+ threads on a bot-authored release PR — with no realistic path here.

Why I am stopping the loop here

Six rounds, and the yield has clearly turned:

  • Rounds 1–5 each found something that would have broken a release or bypassed a review: the parked-CI mechanism, an unterminated $(gh pr create (would have broken PR creation on the first run), a dist check that verified nothing, a thread filter that cleared human comments, and a tracking note that claimed validation the code did not have.
  • Round 6 produced one edge case that does not reproduce against this call site (annotated tags with an exact refspec) and two pagination limits, one of which fails safe and one now closed by a query parameter.

Every remaining item is bounded and fails toward "the release does not happen", never toward "the release publishes something unverified". That is the invariant this work exists to establish, so this is the right place to stop.

Thanks — genuinely. Five of the six rounds changed the code, and three of those were defects I introduced while fixing the previous round. The findings that mattered were all in code that every existing gate reported as green.

Final state, verified across all 21 repositories: YAML parses; every bash run: block passes bash -n; the all-bot thread predicate, comment-pagination guard, parked-run detection with per_page=100, remote-tag read, and the dist rebuild check (where dist is tracked, with reproducibility confirmed locally first) are each present.

@unbraind
unbraind merged commit 4fe6457 into main Aug 10, 2026
6 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant