Three ordinary route handlers, called on a schedule by .github/workflows/cron.yml.
| Route | Schedule (UTC) | Does |
|---|---|---|
/api/cron/payment-reminder | 0 3 * * * | Emails allotted, unpaid delegates |
/api/cron/gform-sync | 30 3 * * * | Re-pulls every configured sheet through intake |
/api/cron/media-sweep | 0 4 * * * | Clears orphaned pending and failed media assets |
Off unless switched on
The workflow does nothing unless the repository variable CRON_ENABLED is true. It exists so
a new scheduler can be merged before the old one is turned off.
Careful
If reminders are not arriving, check this first. A disabled schedule fails silently: the workflow run shows as skipped, and no route is called.
gh variable list
gh run list --workflow cron.yml --limit 5
Only production is scheduled. Staging's routes exist but are never called on a timer.
Authentication
Every route requires Authorization: Bearer $CRON_SECRET. Without a match it rejects the call.
Production and staging have different secrets, so staging cannot trigger production's mailout.
Payment reminder
The most defensive of the three:
- a run lock, so two invocations cannot overlap;
- eighty messages per run, so a backlog cannot become a send burst;
- eight-way concurrency;
- twenty-four hour deduplication per delegate, checked against
EmailLog; - a no-op when payments are disabled, so a free event sends nothing.
Google Form sync
Re-pulls each configured sheet through createDelegateFromRow(). Identity is the email address,
so re-processing cannot create duplicates. A self-heal for webhook outages, not the primary route.
See the intake pipeline.
Media sweep
Removes MediaAsset rows stuck in PENDING or FAILED, the residue of uploads that started and
never finished. See media and uploads.
Running one by hand
From the Actions tab: Cron (production) → Run workflow → pick the job. This runs regardless
of the schedule but still respects CRON_ENABLED.
Locally, against your own dev server:
curl -H "Authorization: Bearer $CRON_SECRET" http://localhost:3000/api/cron/media-sweep
Set EMAIL_REDIRECT_TO first if you run the payment reminder, or it mails whoever is in your
database.
Adding a job
-
Create the route under
src/app/api/cron/. -
Check the bearer token before anything else.
-
Make it idempotent. It will be retried, and it may run twice.
-
Cap the work per invocation. The whole request has five minutes in the workflow's
curl. -
Add a schedule line and a
caseentry incron.yml.