Skip to content

Let a feature release be scheduled instead of babysat - #237

Open
ssavutu wants to merge 4 commits into
mainfrom
feat/scheduled-merge
Open

ssavutu wants to merge 4 commits into
mainfrom
feat/scheduled-merge

Conversation

@ssavutu

@ssavutu ssavutu commented Sep 10, 2026

Copy link
Copy Markdown
Member

Adds .github/workflows/scheduled-merge.yml, so a feature release can be set to
go 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_York in the PR description, and add the
scheduled-merge label. The workflow wakes every ten minutes and merges once
that 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:

  • Not GITHUB_TOKEN: a push made with it does not trigger further
    workflows, and the deploy chain hangs off a workflow_run from a push to
    main. That merge would land the commit and deploy nothing.
  • Not a PAT either: main requires an approving review and a scheduled
    release 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 by
    belonging 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 the
main ruleset as a bypass actor
. Without that last step everything else works
and 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 by
surprise later once the problem clears. Re-add the label to re-arm.

Note it deliberately does not gate on mergeable_state. That field is not
actor-aware: it reports blocked for any PR missing an approval (this PR reads
mergeable: true, mergeable_state: blocked with everything green), and gating on
it 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_dispatch runs the same pass on demand, with pr and dry_run inputs.

Time zones

Merge-at: takes a wall clock plus an IANA zone, resolved via Intl so it stays
right across daylight saving; absolute offsets and Z also work. A wall clock
that 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 zoneinfo across both 2026 US transitions.

The scheduled-merge and scheduled-merge-blocked labels already exist in this
repo.

🤖 Generated with Claude Code

https://claude.ai/code/session_0135YN6NQiVVTqPpjjHC3JDK

ssavutu and others added 4 commits September 10, 2026 01:43
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
@ssavutu

ssavutu commented Sep 12, 2026

Copy link
Copy Markdown
Member Author

Pushed 4126d33 to this branch: the schedule: trigger is gone, leaving workflow_dispatch as the only one.

Scalene's copy of this workflow landed on 2026-09-10 and has been posting a pair of check notifications on main every few hours since — "[Scalene] merge success on main" plus "checks success on main". Nothing was merging; every run ends with No open pull requests carry the "scheduled-merge" label., and the wording is just Discord rendering the merge job's check run, which attaches to main's head SHA because scheduled runs do. Same trigger here would have done the same to this repo's channel.

The measurement from Scalene stands on its own as a reason to drop it: */10 delivered 2 of 58 expected runs over 577 minutes, first fire 4h48m late. The Delta systemd timer (roles/release_scheduler, triangle-infrastructure#3) is the driver, so that PR needs to land and deploy before a release can be timed from this repo — until then a scheduled release here needs a manual dispatch. Scalene#113 makes the matching change there.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VT3WmtV3qBRxmTKN8ABMyQ

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