Skip to content

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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_target triggers 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_dispatch invocations. 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.closed events 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)

Last updated · 319 distilled / 1,201 read