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.