ci(release): scheduled release train auto-merges the Version Packages PR - #96
Conversation
Features were sitting unreleased between manual merges of the "chore: version packages" PR (3 changesets pending right now, oldest 5 days). Bound that latency with a twice-weekly release train: - release-train.yaml (new): Tue/Thu cron finds the open Version Packages PR by its changeset-release/main head branch, gates it on Perry + CI via the existing pr-gate.sh, and squash-merges on green — which triggers publish.yaml on push to main as usual. Alerts Slack instead of silently skipping when the PR is red, when changesets are pending on main with no Version PR (changesets/action failure), or when the PR carries the release:hold escape-hatch label. - pr-gate.sh: optional GATE_LABEL env var so Slack messages name the right flow; defaults to the existing "@openrouter/sdk bump" label. - publish.yaml: hand the changesets step the GH_TOKEN PAT instead of the Actions GITHUB_TOKEN. PRs opened with GITHUB_TOKEN never trigger other workflows, so the Version Packages PR got no CI and no perry/review — the exact checks the train gates its merge on (same reasoning as the checkout token in bump-openrouter-sdk.yaml). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| PR: ${{ steps.find.outputs.pr_number }} | ||
| # REPO comes from the workflow-level env block. | ||
| # On schedule runs `inputs` is empty, so dry_run != true → AUTO_MERGE=true. | ||
| AUTO_MERGE: ${{ inputs.dry_run != true }} |
There was a problem hiding this comment.
📝 Info: dry_run expression style differs from the SDK-bump workflow but evaluates correctly
AUTO_MERGE: ${{ inputs.dry_run != true }} uses the boolean inputs context, whereas .github/workflows/bump-openrouter-sdk.yaml uses the string form github.event.inputs.dry_run != 'true'. On schedule runs inputs.dry_run is null, which GitHub casts to 0 when compared with a boolean, so the expression yields true (auto-merge) as the inline comment claims; with dry_run: true it yields false. Behavior is correct, just stylistically inconsistent with the sibling workflow.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Intentional: this workflow uses the typed boolean inputs context (dry_run is declared type: boolean), where != true is the natural comparison — and on schedule runs inputs.dry_run is null, so the expression yields true (auto-merge), as the inline comment documents. The sibling workflow's string form predates typed inputs here; happy to align it in a follow-up but not worth churn in this PR.
| PR_JSON="$(gh pr list -R "$REPO" \ | ||
| --head "$VERSION_PR_BRANCH" \ | ||
| --state open \ | ||
| --json number,labels \ | ||
| --jq '.[0] // empty')" | ||
|
|
||
| if [ -z "$PR_JSON" ]; then | ||
| echo "pr_number=" >> "$GITHUB_OUTPUT" | ||
| echo "held=false" >> "$GITHUB_OUTPUT" | ||
| echo "No open Version Packages PR." | ||
| exit 0 | ||
| fi | ||
|
|
||
| PR_NUMBER="$(echo "$PR_JSON" | python3 -c 'import sys,json; print(json.load(sys.stdin)["number"])')" | ||
| HELD="$(echo "$PR_JSON" | HOLD_LABEL="$HOLD_LABEL" python3 -c 'import sys,json,os; pr=json.load(sys.stdin); print(str(any(l["name"]==os.environ["HOLD_LABEL"] for l in pr.get("labels",[]))).lower())')" | ||
| echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" | ||
| echo "held=$HELD" >> "$GITHUB_OUTPUT" | ||
| echo "Found Version Packages PR #$PR_NUMBER (held=$HELD)" |
There was a problem hiding this comment.
🔍 Train merges whatever the Version PR contains, even if it lags behind main's changesets
The train only checks that a PR from changeset-release/main exists and is green; it does not verify the PR is up to date with the changesets currently on main. If a publish.yaml run failed to refresh the Version PR after a later changeset landed, the train would still merge it, cutting a release that omits the newer changeset (which then stays pending until the next Version PR). Worth deciding whether a freshness check (e.g. compare PR head commit date / changeset list) belongs in the find step.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Considered, but a lagging Version PR can't silently drop a changeset: changeset files are only deleted by changeset version on the PR branch, so a changeset the PR doesn't include is still on main after the merge — the very next publish.yaml run on main opens a fresh Version PR containing it, and the train's stranded-changesets check alerts if that PR fails to appear. Worst case is a release that ships less than it could have, followed by another release, not a lost change. A freshness check would trade that for added coupling to changesets/action's update timing (race: changeset merges while the train is mid-gate), so leaving it out deliberately.
…updates re-trigger checks changesets/action pushes changeset-release/main with plain `git push`, which uses checkout's persisted credentials — the default GITHUB_TOKEN, whose pushes never trigger workflows. Updates to an already-open Version Packages PR would therefore run no CI / perry/review, and the release train could merge on results from the first revision. Same pattern as the checkout token in bump-openrouter-sdk.yaml. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cortex review —
|
…posure, and smuggled diffs Review feedback from PR #96: - pr-gate.sh: optional BLOCK_LABEL re-checked on every poll and immediately before merge, so a release:hold applied while the gate is mid-poll (up to 30 min) still stops the merge instead of being a snapshot read once at find time. - publish.yaml: stop persisting the cross-repo PAT through install/build/test. checkout uses persist-credentials: false; a dedicated step injects the PAT into the origin URL only after tests, immediately before the changesets step that pushes. Dependency lifecycle scripts and tests can no longer read the PAT from .git/config, while Version PR branch pushes are still PAT-attributed (and therefore still trigger CI + perry/review). - release-train.yaml: diff-scope check before gating — the Version PR may only touch .changeset/*.md, package.json, CHANGELOG.md, and pnpm-lock.yaml. Anything else on changeset-release/main refuses the unattended merge and alerts Slack. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…allowlist, PAT scrub Second review round on PR #96: - release-train.yaml: diff-scope check fails closed — the file list is fetched as its own command (an API error fails the step) and an empty list is refused rather than read as "clean". Allowlist tightened to exactly what `changeset version` produces here (.changeset/*.md and packages/*/package.json|CHANGELOG.md; no root package.json, no lockfile — cf. Version PR #57). - pr-gate.sh: optional EXPECTED_HEAD pin. The train passes the head SHA the scope check vetted; the gate re-reads the head immediately before merging and refuses if it moved, closing the TOCTOU window where commits pushed to changeset-release/main during the up-to-30-min poll would merge unvetted. - publish.yaml: scrub the PAT from the origin URL (always()) right after the last git-push consumer; HOP dispatches use gh api with the env token, so nothing later needs credentials in .git/config. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e-vet, gh-api tag lookup Third review round on PR #96 (Perry + Devin): - pr-gate.sh held(): a failed label lookup now counts as held (fail closed) — the pre-merge hold check is the final safety control, so "couldn't read labels" must not become "no hold". - pr-gate.sh merge: gh pr merge failure now posts a Slack alert before exiting 1. Under set -e it previously exited silently — fatal for the unattended scheduled train. - pr-gate.sh EXPECTED_HEAD + SCOPE_ALLOWLIST: a head that moves mid-gate is re-vetted against the allowlist instead of hard-failing. changesets/action force-pushes the Version PR whenever main moves, so a routine refresh now adopts the new head and re-polls (checks restarted); only a diff outside the allowlist (or unreadable) refuses and alerts. Allowlist regex defined once at workflow level and shared by the scope step and the gate. - publish.yaml HOP C: tag existence check via gh api git/ref (env token) instead of `git ls-remote origin`, which after the credential scrub would silently fail if the repo ever goes private, forcing the SHA fallback every release. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… distinguish hold from API failure Fourth review round on PR #96 (cortex + Devin): - New .github/scripts/verify-version-pr-scope.sh replaces the inline path allowlist. Path check unchanged, plus: (1) file list fetched via `gh api pulls/N/files --paginate`, so padding a PR past the 100-file page cap can't hide an out-of-scope path; (2) package.json patches are content-vetted — every +/- line must be the version field or an internal @openrouter/* dependency range, exactly what `changeset version` emits, so a smuggled lifecycle script in a workspace package.json is refused even though its path is allowlisted. Validated against real Version PR #57 (passes) and PR #96 itself (fails, as it should). - pr-gate.sh: SCOPE_ALLOWLIST regex replaced by SCOPE_SCRIPT — the mid-gate re-vet runs the same script as the initial check instead of a weaker path-only copy. - pr-gate.sh held(): three attempts with backoff, and an unreadable label state is now reported as an API failure (:warning:, exit 1), distinct from a deliberate hold (:double_vertical_bar:, exit 0) — a transient outage no longer produces a green run claiming someone paused the release. Both call sites share check_hold(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…/action, fail-closed head pin, perry clock reset Fifth review round on PR #96: - verify-version-pr-scope.sh: cross-check the enumerated file count against the PR's changed_files — the files endpoint silently caps at 3000 even with --paginate, so a padded PR could hide a path past the cap. Any mismatch refuses. - release-train.yaml find step: filter to !isCrossRepository so a fork branch named changeset-release/main can never be selected, and error if more than one same-repo PR matches instead of picking .[0]. - publish.yaml: pin changesets/action to the v1.9.0 commit SHA — the step now receives the cross-repo PAT, so a floating tag is an exfiltration vector if the action is ever compromised. - pr-gate.sh: REQUIRE_HEAD=true (set by the train) makes an empty EXPECTED_HEAD a hard error instead of silently disabling the TOCTOU pin when step-output wiring breaks. - pr-gate.sh: separate PERRY_START clock, reset when a moved head is adopted — a routine changesets refresh late in the poll no longer trips the "perry/review never appeared" timeout that measures from the run's original start. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ry find-step abort Sixth review round on PR #96: - verify-version-pr-scope.sh: re-read headRefOid after fetching the diff and refuse on drift, so the reported head_sha is always the revision the files came from. - pr-gate.sh: the mid-gate re-vet adopts the head_sha the scope script itself vetted (parsed from its stdout) instead of a SHA observed before the vet ran — a flip-flop push during the vet can no longer get an unvetted head adopted. The merge itself passes --match-head-commit "$EXPECTED_HEAD", making the pin atomic at the GitHub API: any head movement between the pin check and the merge is rejected server-side. - release-train.yaml find step: --base main (the release branch can be PR'd at any base; the train must only ever merge into main), and every abort path now pages — the multiple-match refusal alerts inline, and an ERR trap covers unexpected gh/python failures in both the find and stranded-changesets steps, honoring the header's "alerted, never silently skipped" contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… fix verdict JSON fallback Seventh review round on PR #96: - verify-version-pr-scope.sh: fetch the diff via compare/<base>...<HEAD_SHA> instead of the mutable pulls/N/files endpoint. The vetted content is now cryptographically bound to the reported head_sha — a blind A→B→A flip-flop timed inside the script can no longer get revision B's files vetted under A's SHA. The endpoint-drift recheck becomes unnecessary and is removed; the enumerated-vs-declared count cross-check stays (compare caps its files array too). - pr-gate.sh: the pre-merge headRefOid read is guarded like the label read — API failure or empty result alerts Slack and exits 1 instead of dying silently under set -e. - pr-gate.sh verdict(): `$(gh ... || echo '[]')` would append the fallback AFTER real output when gh exits non-zero yet still prints JSON (exit 8 = pending, 1 = failing), corrupting the parse (verified by simulation). Capture first, substitute only when empty. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… is adopted A changesets/action refresh late in the 30-minute window left the new head's CI only the remainder of the original deadline, producing a false "did not settle" page and postponing the release to the next scheduled run. Adopting a re-vetted head now restarts START alongside PERRY_START — the same reasoning already applied to the perry clock. Unbounded extension isn't a concern: adoption requires passing the scope re-vet, and out-of-scope pushes still exit 1 immediately. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Problem
The release flow has automation on both ends but a manual hinge in the middle: changesets/action maintains the "chore: version packages" PR, but a human has to merge it before anything publishes. Features sit unreleased between manual merges — right now there are 3 pending changesets (doom-loop detection, doom-loop escalation, run cancellation), the oldest merged to main 5 days ago, and the last release was 12 days ago.
Changes
New:
release-train.yamlTwice-weekly cron (Tue/Thu 09:23 UTC) +
workflow_dispatchwithdry_run:changeset-release/mainhead branchpr-gate.sh(Perry + CI + mergeable, settle re-check) and squash-merges on green — the merge push triggerspublish.yaml, which publishes and fires HOP B/C as usualCI_RELEASE_ALERTSlack channel instead of silently skipping when:pr-gate.sh, run goes red)release:holdlabel (deliberate pause, e.g. coordinated@openrouter/agent+@openrouter/sdkreleases; posts a paused notice)pr-gate.shOptional
GATE_LABELenv var so Slack messages name the flow that's gating; defaults to the existing@openrouter/sdk bumplabel, so the SDK-bump workflow is unchanged.publish.yaml— required token fixThe changesets step now uses the
GH_TOKENPAT instead of the ActionsGITHUB_TOKEN. PRs opened withGITHUB_TOKENnever trigger other workflows, so the Version Packages PR currently gets no CI and no perry/review — exactly the checks the train gates on. Same reasoning as the checkout token inbump-openrouter-sdk.yaml.Follow-ups (GitHub settings, not in this diff)
release:holdlabel (train treats its absence as "not held")workflow_dispatchrun withdry_run: trueto watch the full find → gate path against the real pending Version PR without cutting a release🤖 Generated with Claude Code