Skip to content

ci(release): scheduled release train auto-merges the Version Packages PR - #96

Merged
LukasParke merged 11 commits into
mainfrom
LukasParke/package-release-flow
Aug 4, 2026
Merged

ci(release): scheduled release train auto-merges the Version Packages PR#96
LukasParke merged 11 commits into
mainfrom
LukasParke/package-release-flow

Conversation

@LukasParke

@LukasParke LukasParke commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

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.yaml

Twice-weekly cron (Tue/Thu 09:23 UTC) + workflow_dispatch with dry_run:

  1. Finds the open Version Packages PR by its fixed changeset-release/main head branch
  2. Gates it through the existing pr-gate.sh (Perry + CI + mergeable, settle re-check) and squash-merges on green — the merge push triggers publish.yaml, which publishes and fires HOP B/C as usual
  3. Alerts the CI_RELEASE_ALERT Slack channel instead of silently skipping when:
    • the PR is red or stuck (via pr-gate.sh, run goes red)
    • changesets are pending on main but no Version PR exists (changesets/action failed to open one — previously undetectable)
    • the PR carries the release:hold label (deliberate pause, e.g. coordinated @openrouter/agent + @openrouter/sdk releases; posts a paused notice)

pr-gate.sh

Optional GATE_LABEL env var so Slack messages name the flow that's gating; defaults to the existing @openrouter/sdk bump label, so the SDK-bump workflow is unchanged.

publish.yaml — required token fix

The changesets step now uses the GH_TOKEN PAT instead of the Actions GITHUB_TOKEN. PRs opened with GITHUB_TOKEN never 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 in bump-openrouter-sdk.yaml.

Follow-ups (GitHub settings, not in this diff)

  • Create the release:hold label (train treats its absence as "not held")
  • After merge, do a first workflow_dispatch run with dry_run: true to watch the full find → gate path against the real pending Version PR without cutting a release

🤖 Generated with Claude Code


Open in Devin Review

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>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 4 potential issues.

Open in Devin Review

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 }}

@devin-ai-integration devin-ai-integration Bot Aug 3, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📝 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread .github/workflows/publish.yaml
Comment thread .github/workflows/publish.yaml
Comment thread .github/workflows/publish.yaml
@LukasParke LukasParke added the cortex-keep-updated cortex keeps this PR up to date with its base branch label Aug 3, 2026

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 2 new potential issues.

Open in Devin Review

Comment thread .github/workflows/publish.yaml
Comment thread .github/workflows/release-train.yaml Outdated
Comment on lines +61 to +78
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)"

@devin-ai-integration devin-ai-integration Bot Aug 3, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔍 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.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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>
devin-ai-integration[bot]

This comment was marked as resolved.

@cortex-github-agent

cortex-github-agent Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor
  • Keep up to date — merge the base branch into this PR as it moves
  • Merge when ready — GitHub auto-merges once its required checks and approvals pass

cortex review — 8012521

Security · ✅ Experience (DX · UX · A11y) · ✅ Performance

Automatic first-pass review · updated in place on every push

cortex-github-agent[bot]

This comment was marked as resolved.

…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>
devin-ai-integration[bot]

This comment was marked as resolved.

…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>
perry-the-pr-reviewer[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

cortex-github-agent[bot]

This comment was marked as resolved.

…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>
cortex-github-agent[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

… 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>
cortex-github-agent[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

…/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>
cortex-github-agent[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

…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>
cortex-github-agent[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

… 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>
devin-ai-integration[bot]

This comment was marked as resolved.

… 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>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

Open in Devin Review

Comment thread .github/workflows/publish.yaml
@LukasParke
LukasParke merged commit 1ac98c8 into main Aug 4, 2026
6 checks passed
@LukasParke
LukasParke deleted the LukasParke/package-release-flow branch August 4, 2026 01:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cortex-keep-updated cortex keeps this PR up to date with its base branch

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant