PATTERN Cited by 1 source
Stateful GitHub Actions cron¶
Intent¶
Run a bot as a scheduled GitHub Actions workflow for compute, but keep persistent state in an external database rather than re-deriving state from PRs / issues / comments on every run. Makes incremental processing ("handle anything new since last run") cheap and correct without standing up a dedicated server.
When to use it¶
- You want the observability, free-ish compute, and zero-ops nature of GitHub Actions.
- The work involves processing items in order (PRs, issues, events) and reprocessing would be wasteful or incorrect.
- Your state fits in a small relational database — tens of thousands of rows is typical.
- Stateless encoding of state "in PRs" is possible but painful: full history scans, fragile comment parsing, rate-limit concerns.
The stateless vs stateful choice¶
A stateless bot would store state in the things it acts on: - Labels on the PRs it's processed. - Comments with machine-readable markers. - Custom check-run statuses.
PlanetScale considered this and chose stateful: "Should it be stateless, storing all information in PRs, or stateful with a dedicated data store? After extensive deliberation, we opted for a solution that runs periodically on a cron schedule using GitHub Actions, with its state stored in a PlanetScale database instance." (Source: sources/2026-04-21-planetscale-automating-cherry-picks-between-oss-and-private-forks)
Reasons stateful wins for this class of bot:
- Incremental pull with a stopping criterion. The bot walks OSS-side closed PRs newest-first and stops at the first one whose timestamp predates any PR already in the database. Stateless equivalent would require scanning every PR ever merged looking for an absence-of-marker — expensive and rate-limit-bound.
- Reconciliation is a join, not a scrape. Weekly audit (patterns/weekly-reconciliation-check) compares DB rows against the current state of PRs; stateless reconciliation would have to re-derive every item's status from scratch.
- Retry and idempotency. Bot's run log + status per PR makes it straightforward to retry only failed items. Stateless encoding tangles retry logic into marker-parsing code.
- Stronger schema guarantees. A typed DB prevents the class of bugs where label format drift breaks the bot (e.g. someone renamed a label, bot's regex now misses everything).
Mechanism¶
Two moving parts that share the state store:
Scheduled workflow (compute)¶
A GitHub Actions workflow with a schedule: trigger, e.g. hourly:
on:
schedule:
- cron: '0 * * * *'
workflow_dispatch: {} # allow manual re-runs
jobs:
run-bot:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run bot
env:
DB_URL: ${{ secrets.DB_URL }}
GH_TOKEN: ${{ secrets.BOT_TOKEN }}
run: go run ./cmd/bot
External state store¶
A relational DB (PlanetScale's case study uses their own product, but Postgres / MySQL / SQLite-over-Litestream all work). Minimal schema for a PR-processing bot:
CREATE TABLE processed_prs (
pr_id BIGINT PRIMARY KEY,
merged_at TIMESTAMP NOT NULL,
status TEXT NOT NULL, -- 'pending' | 'processed' | 'conflict'
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_merged_at ON processed_prs(merged_at DESC);
The incremental-pull stopping criterion reads the newest
merged_at from the DB and walks upstream PRs until an older one
is seen.
Consequences¶
Benefits¶
- Cheap to operate — GitHub Actions minutes are free for public repos and included in most org plans. No server to maintain.
- Observable — every run has a log, a status, and a re-run button in the GitHub UI.
- Incremental by construction — the DB state makes each run touch only new items.
- Supports reconciliation naturally — the DB is the canonical "what we've done" store; audits are queries.
- Low-ceremony secrets — GitHub Actions' encrypted secrets store the DB credential and API token.
Costs / pitfalls¶
- GitHub Actions scheduling is best-effort — cron jobs can be delayed under GitHub-wide load (especially at the top of the hour on busy orgs). If timing is strict, use a self-hosted scheduler.
- No overlap protection by default — two scheduled runs can
start close together; use
concurrency:in the workflow to serialise. - State-store dependency — bot is down when the DB is.
- Secrets exposure surface — a compromised workflow or a
malicious PR running in an unsafe context can exfiltrate DB
credentials. Minimize scopes; never run this kind of bot on
pull_request_targettriggers with attacker-influenced inputs. See concepts/github-actions-script-injection. - Rate limits still apply —
go-github-style clients need conditional requests / ETags to stay inside API budgets.
Variations¶
- Single long-running workflow — loop inside the job, sleep between iterations. Cheaper on cron-scheduling variance but the maximum job duration (6h) bounds it, and you lose per-iteration observability.
- Per-item workflow re-entry — have run 1 process a batch and
enqueue follow-up
workflow_dispatchinvocations. Finer-grained observability at the cost of more moving parts. - Self-hosted runner for private network access to the state DB (avoids exposing DB publicly).
- Reactive triggers to augment cron — e.g. react to
pull_request.closedevents to process new PRs immediately, with cron as a backstop for missed events.
Canonical instantiation¶
systems/vitess-cherry-pick-bot runs this exact pattern:
GitHub Actions cron on an hourly schedule, state in a PlanetScale
database, go-github for API access, with a weekly secondary
schedule for reconciliation. Reportedly in production for 1.5+ years
at time of the post. (Source:
sources/2026-04-21-planetscale-automating-cherry-picks-between-oss-and-private-forks)