The genre I keep returning to is the daily thing: a Wordle-like with a new answer each morning (One Piece Guess), a fan page whose ladder and fixtures refresh themselves (Side by Side), a news digest that resweeps its sources at 7am (the Daily Signal). They feel alive, and people come back to alive things. The trick is that "alive" costs nothing: GitHub Actions runs your script on a schedule, commits the result into the repo, and GitHub Pages serves it as static files. The repository is the database. The workflow is the backend.

The three moving parts

1 — An idempotent script

The script's only job is to compute today's state and write it to a JSON file. The one property it must have is idempotence: run it twice, and the second run changes nothing. This is what makes retries, manual dispatches, and overlapping runs completely safe — and it's the single most transferable lesson in this whole guide. Every scheduled system you ever build, from this toy to a production billing sweep, gets its reliability from the same property.

def main():
    today = game_date()                # in YOUR timezone, not UTC
    history = load("data/history.json")

    if any(p["date"] == today for p in history["picks"]):
        print("today already exists — nothing to do")
        return                         # ← the idempotence check

    pick = choose(exclude=last_n(history, 30))   # no repeats
    append(history, today, pick)
    write("data/daily.json", {"date": today, "pick": encode(pick)})

Details that matter: compute "today" in the timezone your players wake up in, not UTC — the day should tick over with their morning coffee. Keep a history file so the picker can refuse to repeat itself. And if the answer must be secret-ish, encode it (base64 is fine — Wordle shipped its whole answer list to the client for years; cheaters only cheat themselves).

2 — A workflow that picks, commits, and deploys

Three triggers, one job:

on:
  push:              # every push deploys
    branches: [main]
  schedule:
    - cron: "15 21 * * *"   # 21:15 UTC ≈ 7am Melbourne
  workflow_dispatch:        # a manual "run it now" button

permissions:
  contents: write           # or the bot's push gets rejected

On scheduled and manual runs, the job executes the picker and commits whatever changed:

- run: python3 scripts/pick_daily.py
- run: |
    git config user.name "daily-bot"
    git add data/
    git diff --cached --quiet || \
      (git commit -m "daily: refresh [skip ci]" && git push)

Then the same job uploads the site and deploys to Pages. Doing the pick and the deploy in one job means they can never race each other — the site you deploy always contains the data you just committed.

3 — A client that trusts the JSON but has a fallback

The page fetches data/daily.json and plays whatever it says. If the fetch fails — local development, a cron that died — it falls back to a deterministic pick seeded by the date, so the game never shows a blank screen. The cron doesn't make the game work; it makes it better: true randomness, no-repeat memory, a stable puzzle number everyone shares.

The pitfalls, so you don't collect them yourself

  • [skip ci] in the bot's commit message. The bot pushes to main; pushes to main trigger the workflow. Without this tag you've built an infinite loop with your free minutes.
  • permissions: contents: write — the default token is read-only, and the error you get without it is not obviously about permissions. Budget an afternoon or copy this line.
  • GitHub cron is best-effort. It fires minutes late under load, occasionally very late. Design for "roughly morning", never "exactly 7:00:00" — nothing in your system should depend on punctuality.
  • The 60-day timeout. Scheduled workflows are disabled after 60 days of repo inactivity. The elegant part: the bot's daily commit counts as activity, so a working daily cron keeps itself alive forever. A weekly one might not.
  • One try/except per output file. Side by Side pulls from two sources — the Squiggle API and Google News. Either can flake. A single failure should skip one file, not blank both.

Why this is the way to learn cron

Most people meet cron for the first time inside a production system, where the stakes are real and the feedback loop is a pager. A daily game inverts that: the stakes are zero, the feedback is a friend texting you their score, and yet you touch every real concept — scheduling syntax, idempotence, timezone maths, permissions, race conditions, dead-man switches, graceful degradation. When you later meet a "refresh the materialised view nightly" ticket at work, you've already made every mistake on a pirate-guessing game.

Reskin it and it's yours: swap the character roster for Premier League players, horror films, Pokémon; swap the club config in Side by Side for your team. The engine — picker, workflow, fallback — carries over untouched. Both repos are open: one-piece-guess-game and collingwood-fan-suite, each with a README that walks the setup end to end.