Chapters 5 and 6 covered gates and deploys, both triggered by something happening: a push, a PR. This chapter is about the two triggers that fire when nothing happens, on a clock, or when you personally reach for a lever. Both are more fragile than they look, and the more important lesson is that a scheduled job is a decision you can also decline to make.
The schedule trigger and why it lies to you about time
A scheduled workflow looks like this:
on:
schedule:
- cron: '30 5 * * 1-5'
Five fields, standard cron syntax, and an optional timezone key if you don't want to do UTC arithmetic in your head. That much is exactly what it looks like: a job that runs at 05:30 on weekdays.
What it does not do is run at 05:30 on weekdays, reliably. GitHub's own documentation says so directly: "the schedule event can be delayed during periods of high loads of GitHub Actions workflow runs," and "the shortest interval you can run scheduled workflows is once every 5 minutes." That second line rules out sub-5-minute polling outright, no matter how you write the cron expression. The first line is the one that catches people out in practice: at 05:30 sharp on a day when a lot of the world's Actions runners are busy, your job queues behind everyone else's, and it can run at 05:34, or later.
If your job genuinely cannot tolerate that kind of drift, schedule is the wrong trigger and Actions is probably the wrong platform, a point this chapter comes back to.
workflow_dispatch: the manual lever
The other trigger that doesn't depend on a push is workflow_dispatch. It puts a "Run workflow" button in the Actions tab and a gh workflow run command in your terminal, and it can carry typed inputs:
on:
workflow_dispatch:
inputs:
environment:
description: "Target environment"
required: true
default: "staging"
type: choice
options:
- staging
- production
Inputs are declared under on.workflow_dispatch.inputs and read inside the job as ${{ inputs.environment }}. GitHub documents ceilings on both sides of that: a maximum of 25 top-level input properties per workflow, and a 65,535-character limit on the whole input payload. You will not hit either running a personal repo, but it tells you the feature is meant for small, structured parameters, not a place to smuggle a JSON blob through.
Dispatch matters for a reason that has nothing to do with convenience: it's the fallback for every scheduled job. Anything that can run on a clock should also be runnable by hand, because the clock will eventually fail you, or you'll want to run it right now instead of waiting for the next tick.
gh workflow run refresh-workshop.yml
Concurrency groups: queue, don't collide
Scheduled and dispatched runs share a problem push-triggered workflows mostly don't: they can overlap with themselves. A schedule fires while yesterday's manual run is still going, or someone clicks "Run workflow" twice. concurrency is the documented mechanism for controlling that:
concurrency:
group: refresh-workshop
cancel-in-progress: false
cancel-in-progress: true kills whatever's currently running in the same group and starts the new one. false makes the new run wait its turn. Both are correct, for different jobs. This site's refresh-workshop.yml and deploy-backend.yml both use false, on purpose: a cancelled mid-write would leave a half-committed JSON file or a half-synced FTP directory, either of which is worse than a job that waits ninety seconds. The push/PR deploy workflow in chapter 6, by contrast, cancels in-progress runs, because an outdated build artefact from three commits ago is worth throwing away the moment a newer one exists. The rule isn't "always cancel" or "never cancel." It's: does an interrupted run leave something half-done that matters? If yes, queue. If no, cancel and move on.
The workshop-refresh trade
Captain Random's refresh-workshop.yml used to run daily on a schedule, pulling XP and activity data for the learning hub. It doesn't any more. The schedule block is gone from the on: section entirely, replaced with workflow_dispatch only, and the comment at the top of the file explains why: moving the daily run off GitHub Actions onto an always-on Mac's launchd saves roughly 75% of the private repo's monthly Actions-minute budget, which on the GitHub Free tier is a fixed 2,000 minutes a month. A daily job that ran fine on Actions was still spending minutes every single day for something a machine that's already on 24/7 can do for nothing.
The workflow file didn't get deleted. It stayed as a manual fallback, useful if the local launchd job breaks, if the machine is offline for a stretch, or if a one-off mid-day refresh is wanted without touching the laptop. That's the shape worth copying: when you move a scheduled job off Actions, keep the Actions version alive as workflow_dispatch-only. You lose nothing by keeping it and you gain a lever for the day the primary path fails.
The fallback also surfaced a token problem that's worth knowing about before you hit it yourself. The job needs to read other private repos (for the endpoints the XP fetchers query) and push the regenerated data back to main. The default GITHUB_TOKEN that every workflow gets automatically can't do either: it has no access to other repos, and even where it could push, commits made with it don't retrigger downstream workflows. refresh-workshop.yml uses a repo secret, GH_LOC_TOKEN, a classic PAT with the repo scope, specifically to get both a wider read and a push that actually fires deploy.yml afterwards.
When the answer is local launchd, not Actions
The workshop-refresh trade was about cost. There's a second, sharper reason to keep a job off Actions: the job needs something that only exists on your own machine.
launchd is what macOS actually maintains as a scheduler; cron is still present but effectively frozen. A launchd agent runs under your own login, with access to your Keychain, your locally-authenticated CLI tools, and any state you've built up on disk that never touched git. None of that exists on a GitHub-hosted ubuntu-latest runner, which starts from a clean image every single time.
That distinction sounds abstract until you hit a job that fails it in practice, which is exactly what happened on UK Calculators' editorial pipeline.
The cron that stays off on purpose
UK Calculators' editorial-monitor.yml has a schedule: block for a 3-hour cron heartbeat. It's commented out in the YAML, not deleted, not merged, sitting there literally disabled. The comment above it names two named preconditions that have to clear before it gets switched back on: a state-persistence design (tracked as backlog item B-012) and a CI-friendly way for the pipeline to reach Claude. Until both exist, the schedule stays off, and the repo runs the workflow by hand via workflow_dispatch instead.
The second precondition is the one that matters here, and it's a clean example of "this can't run unattended, so don't pretend it can." The full-cycle job's triage.py step calls the claude CLI locally, authenticated under the operator's own Claude subscription. That auth doesn't exist on a GitHub-hosted runner: there's no claude binary installed, and no session to authenticate with even if there were. So the dispatch-only job short-circuits that step by design rather than trying to fake it.
The obvious fix looks like putting an API key in a repo secret and calling the API directly instead of shelling out to a local CLI. That was considered, and explicitly rejected in the spec. It's not a technical impossibility, it's a decision: the pipeline is built to run under a personal subscription's auth model, not a metered API key sitting in secrets.*, and swapping that in to satisfy a scheduler would mean building and maintaining a second auth path just to make a cron tick.
Judgement call: what belongs in Actions
Both stories point at the same test, applied twice. refresh-workshop.yml asks "can this run somewhere cheaper," and the answer was yes, because the job needed nothing but network access and a token, and a machine that's already running around the clock could do it for free. editorial-monitor.yml asks "can this run somewhere else at all," and for the triage step, the answer was no, because the job needs local, personally-authenticated state that a fresh cloud runner structurally cannot have.
Put together as a checklist before you write a schedule: block:
- Does the job need local state, local auth, or a locally-installed tool that isn't scriptable through a repo secret? If yes, that's a
launchd(orcron, on Linux) job, not an Actions job, full stop. - Is the job cheap enough to run in the cloud but running on hardware you already pay for and leave idle? Consider moving it local anyway, and keep the Actions version as a
workflow_dispatchfallback rather than deleting it. - Does the job depend on firing at an exact minute? If so, schedule's documented delay under load means Actions is the wrong trigger regardless of where the job runs.
- If none of the above apply, cron on Actions is fine. Give it a
concurrencygroup, decide deliberately whether overlapping runs should queue or cancel, and addworkflow_dispatchalongside the schedule so a human can always run it on demand.
The instinct to automate everything on a schedule the moment it's possible is the thing to resist. A cron block that's commented out with a reason attached is doing its job just as much as one that fires every three hours.
Give a scheduled job a manual lever and a queueing rule
Take (or write) a workflow in your own repo that runs on a schedule, and harden it the way refresh-workshop.yml is hardened: a workflow_dispatch fallback with at least one input, and a concurrency group with an explicit cancel-in-progress decision you can justify.
Expected behaviour
- A workflow with both on.schedule (cron syntax) and on.workflow_dispatch, the latter declaring at least one typed input under inputs:
- A concurrency block naming a group and setting cancel-in-progress explicitly to true or false, with a one-line comment saying why that value and not the other
- A step reading the dispatch input via ${{ inputs.<name> }} and using it (in a log line is enough for this exercise)
- A successful dispatched run visible in the Actions tab, triggered without waiting for the schedule to fire
PROVE IT gh workflow run <file>.yml -f <input-name>=<value> && gh run list --workflow=<file>.yml --limit 1
Why shouldn't a workflow depend on its schedule trigger firing at the exact minute written in the cron expression?
What is the name of the repo secret that refresh-workshop.yml's manual-fallback job needs, because the default GITHUB_TOKEN can't read the other private repos it queries or reliably retrigger deploy.yml?
editorial-monitor.yml's schedule block is commented out, and its triage step can't run unattended on GitHub's ubuntu-latest runner. Explain what triage.py actually depends on, and why a repo-secret API key wasn't chosen as the workaround.
Show answer
triage.py shells out to the local claude CLI authenticated under the operator's own Claude subscription, and that authenticated session only exists on the machine where it was set up, not on a fresh cloud runner that has neither the binary nor the login. Swapping in a metered API key stored as a repo secret would technically unblock the runner, but it was explicitly rejected in the spec because it means building and maintaining a second auth path purely to satisfy a scheduler, when the pipeline is deliberately designed around personal subscription auth rather than API billing.
↺ re-read: “The cron that stays off on purpose”