Conversation
Timing a release currently means someone sitting at a keyboard at the release moment to click Merge, because merging main is what starts the deploy chain. This adds a scheduled-merge workflow: label a PR `scheduled-merge`, put a `Merge-at:` line in its description, and it merges itself once that time passes. The merge deliberately uses a RELEASE_BOT_TOKEN secret rather than the built-in GITHUB_TOKEN, and refuses to run without it. A push made with GITHUB_TOKEN does not trigger further workflows, and the deploy chain hangs off a workflow_run from a push to main -- so a GITHUB_TOKEN merge would land the commit and then deploy nothing, which is the one failure a timed release must not have. Before merging it requires the PR to be open, non-draft, conflict-free, unblocked, and to have every check run and commit status finished and passing; a PR with no checks at all is refused rather than trusted. Checks still running mean "not yet" and are retried on the next tick, while a real problem comments once, swaps the label for scheduled-merge-blocked, and stops -- so a broken release never merges by surprise once the problem clears. `Merge-at:` takes a wall-clock time plus an IANA zone, resolved through Intl so it stays correct across daylight saving; a wall clock the spring-forward jump skips over is rejected rather than guessed at, and the ambiguous fall-back hour resolves to its earlier instant. Both forms are verified against zoneinfo across the 2026 transitions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0135YN6NQiVVTqPpjjHC3JDK
Two corrections to the scheduled-merge workflow, both from the fact that main normally requires an approving review. The first version was written against a ruleset that temporarily had that requirement switched off, so neither showed up. mergeable_state is not actor-aware. GitHub reports "blocked" for any PR missing a required approval -- triangle-cms#237 reads `mergeable: true, mergeable_state: blocked` with every check green -- and that field describes the state for an ordinary merger, saying nothing about bypass actors. Refusing on it, as the first version did, would have cancelled every scheduled release and kept doing so even once a bypass identity was wired up correctly. It is now logged and the merge proceeds; GitHub stays the authority, and a genuine refusal is reported by the existing error path rather than predicted here. The safety properties are unchanged, because they come from the checks this workflow verifies itself. Merging therefore has to bypass the review rule, which decides the identity. A ruleset can name a GitHub App as a bypass actor (actor_type: Integration); a PAT can only bypass by belonging to an org admin, which would mean keeping an org-admin-grade credential in a repository secret to merge one pull request. So this swaps RELEASE_BOT_TOKEN for RELEASE_BOT_APP_ID + RELEASE_BOT_PRIVATE_KEY and mints a short-lived installation token per run. That also still satisfies the original reason for not using GITHUB_TOKEN: an App push triggers the deploy chain. The README now spells out the App's five permissions, the install step, and that adding the label is what authorises skipping review. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0135YN6NQiVVTqPpjjHC3JDK
Merging on time only got the release half way. The production environment has required reviewers, so the deployment stops and waits for a human even after the scheduled merge lands -- a midnight release would sit merged but unpublished until someone woke up. Scalene #109 went out tonight only because a reviewer approved the deploy by hand. Environments have no bypass-actor concept, so unlike the branch ruleset the release App cannot simply be exempted. The deploy workflow now picks its environment from who pushed the commit: pushes by the release App go to a new production-auto environment, carrying the same deployment branch policy and the same DELTA_* variables but no required reviewers. Everything else still lands on production and still waits for a reviewer, and a manual workflow_dispatch has no workflow_run actor so it falls through to the gated environment as well. Note this repo chains through two workflow_run hops (CI -> Publish Images -> Deploy Delta) where Scalene chains through one, so it depends on the actor propagating across both. That is the first thing to check if a scheduled CMS release ever stops at the reviewer gate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0135YN6NQiVVTqPpjjHC3JDK
Scalene, which carries this same workflow, has been posting a pair of check notifications on `main` every few hours since its copy landed -- empty ticks with no labelled pull request to consider, reading as "merge success on main" because that is the name of the job. Measured there, `*/10` also delivered only 2 of 58 expected runs, the first 4h48m late, so the trigger was not earning the noise. The Delta systemd timer (roles/release_scheduler) fires this workflow instead. Doing it on this branch rather than after merging, since this PR is where the cron would arrive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VT3WmtV3qBRxmTKN8ABMyQ
|
Pushed Scalene's copy of this workflow landed on 2026-09-10 and has been posting a pair of check notifications on The measurement from Scalene stands on its own as a reason to drop it: 🤖 Generated with Claude Code |
Adds
.github/workflows/scheduled-merge.yml, so a feature release can be set togo live at a chosen time instead of needing someone at a keyboard to click Merge
at that moment.
How it is used: open the PR normally, let CI go green, put a line like
Merge-at: 2026-09-12 00:01 America/New_Yorkin the PR description, and add thescheduled-mergelabel. The workflow wakes every ten minutes and merges oncethat time has passed, which starts the existing deploy chain.
Setup required before this works
It merges as a GitHub App, and fails loudly rather than merging if the
credentials are absent. Two independent constraints force an App, neither with a
workaround:
GITHUB_TOKEN: a push made with it does not trigger furtherworkflows, and the deploy chain hangs off a
workflow_runfrom a push tomain. That merge would land the commit and deploy nothing.mainrequires an approving review and a scheduledrelease merges without one, so the merger must be a ruleset bypass actor. A
ruleset can name an App (
actor_type: Integration); a PAT bypasses only bybelonging to an org admin, which means an org-admin-grade credential sitting
in a repo secret to merge one PR.
Create an App with Contents RW, Pull requests RW, Checks R, Commit statuses R,
Metadata R and nothing else, install it on this repo, store its ID and private
key as
RELEASE_BOT_APP_ID/RELEASE_BOT_PRIVATE_KEY, and add it to themainruleset as a bypass actor. Without that last step everything else worksand the merge is still refused. The workflow mints a short-lived installation
token per run, so only the private key is long-lived.
Safety before it merges
Open, non-draft, conflict-free, and every check run and commit status on the head
commit finished and passing. Checks still running mean "not yet" and are retried
next tick. A PR with no checks is refused rather than trusted. On a real
problem it comments once with the reason, swaps the label for
scheduled-merge-blocked, and stops — so a broken release cannot merge bysurprise later once the problem clears. Re-add the label to re-arm.
Note it deliberately does not gate on
mergeable_state. That field is notactor-aware: it reports
blockedfor any PR missing an approval (this PR readsmergeable: true, mergeable_state: blockedwith everything green), and gating onit would cancel every scheduled release even with the bypass wired up correctly.
A scheduled release skips code review by design; adding the label is what
authorises that.
Timing
GitHub's scheduled-workflow queue is best-effort and often several minutes late,
so
Merge-at:means not before that time (in practice within ~15 minutes).workflow_dispatchruns the same pass on demand, withpranddry_runinputs.Time zones
Merge-at:takes a wall clock plus an IANA zone, resolved viaIntlso it staysright across daylight saving; absolute offsets and
Zalso work. A wall clockthat the spring-forward jump skips over is rejected rather than guessed at, and
the ambiguous fall-back hour resolves to its earlier instant. The parser was
checked against Python's
zoneinfoacross both 2026 US transitions.The
scheduled-mergeandscheduled-merge-blockedlabels already exist in thisrepo.
🤖 Generated with Claude Code
https://claude.ai/code/session_0135YN6NQiVVTqPpjjHC3JDK