Runs GitHub Actions workflows on a schedule, from a Cloudflare Worker.
Once, by hand.
-
Create an App under
jshvnwith one permission -- Repository permissions -> Actions -> Read and write -- and no webhook. -
Install it on the
katoptraorganization with access to all repositories, so a repo created or transferred there is covered without another step, and onjshvnfor the repos that stay there. The Worker finds each target's installation from its repo name at dispatch time, so there is no installation ID to note. -
Note the App ID from the App's settings page.
-
Generate a private key and convert it. GitHub issues PKCS#1; WebCrypto imports PKCS#8 only.
task pkcs8 KEY=app.private-key.pem # writes app.pkcs8.pem
-
Run
task secrets. It prompts forGITHUB_APP_ID, then readsGITHUB_APP_PRIVATE_KEYfromapp.pkcs8.pem.KEY=points it at another path.The key is piped rather than pasted because
wrangler secret putreads a single line when it has a terminal: a pasted PEM would store itsBEGINline and nothing else, and that is a non-empty value, so wrangler takes it and the first dispatch is where it shows. -
Delete both
.pemfiles.
- My Profile -> API Tokens -> Create Token -> Create Custom Token.
- Give it one permission: Account -> Workers Scripts -> Edit. Add Account -> Workers Tail
-> Read to run
task logswith it. - Under Account Resources, include only the account this Worker lives in.
- Put the token above and the account ID (Workers & Pages overview) in 1Password: vault
jshvn, itemdispatch, sectioncloudflare, fieldstokenandaccount_id. - Settings -> Secrets and variables -> Actions:
OP_SERVICE_ACCOUNT_TOKEN, the service account that reads that vault. Every jshvn repository holds the same value, so rotating it is onegh secret setper repository.
Push to main deploys. The checks run on every push without the secret.
Five slots, named in schedules/index.ts, each a cron trigger of its own:
| slot | UTC | Pacific, winter |
|---|---|---|
hourly |
:42 | :42 |
overnight |
11:17 | 03:17 |
morning |
17:17 | 09:17 |
afternoon |
23:17 | 15:17 |
evening |
05:17 | 21:17 |
One file per GitHub repo in schedules/, named for the half of owner/name after the
slash. It lists that repo's workflows and the slots each one runs in, and ends in
as const so a misspelt slot fails to compile:
// schedules/ctan.ts
export default {
repo: "katoptra/ctan",
workflows: [{ workflow: "sync.yml", slots: ["hourly"] }],
} as constschedules/index.ts imports every one of them. Bundling is static, so there is no glob and
a file the registry omits never runs.
Cloudflare needs the slots' cron strings in wrangler.jsonc, which is JSON and cannot
import them. task crons writes the ones in use there; task check fails until it has
been run. Registering a workflow in a slot that already has a target changes nothing in
wrangler.jsonc.
To add one:
- Write
schedules/<name>.tsand add its import toschedules/index.ts. task crons, if the slot had no target before.- Under
katoptrathe App already covers it. Underjshvn, add the repo to the App's installation there. A repo the App cannot see fails its step for good withApp is not installed on <owner>/<name>, whichtask inspectshows. - Check three things in the target's own workflow. Nothing here can, and a target failing
any of them is dispatched into silence:
workflow_dispatch:in itson:block, or the dispatch 404s.- a
concurrencygroup withcancel-in-progress: false, so a retried dispatch queues instead of doubling the work. - a healthcheck ping. This repo never learns whether a run passed.
task check, then push tomain.
To remove one:
- Delete
schedules/<name>.tsand its import fromschedules/index.ts. task crons, if it was the slot's only target.- Give that workflow a
schedule:of its own.schedules/was its only clock. task check, then push tomain.
task on its own prints the menu.
task check-- everything CI runs: types, format, tests, dry-run deploy.task targets-- what gets dispatched, and when.task crons-- writewrangler.jsonc's triggers fromschedules/.task runs-- recent runs of each target on GitHub.task instances-- the Cloudflare side, one instance per cron that fired.task inspect-- one instance's steps, retries and errors.ID=<id>, default latest.task format-- fix formatting in place.task clean-- deletenode_modules,.wranglerandworker-configuration.d.ts. The next task needing them runsnpm ciitself, so there is nothing to remember. It keeps.pemfiles and.dev.vars, which it cannot rebuild, and says so when either is there.task dev,task logs,task deploy-- run locally, live logs, deploy by hand.
To prove a change for real, trigger a production instance with the payload a cron would have given it:
npx wrangler workflows trigger dispatch '{"cron":"42 * * * *","scheduledTime":0}'It dispatches for real. Without the JSON the instance has no cron to look up and throws.
- Cloudflare parses the crons; this repo only looks up strings, so
0 * * * *and0 */1 * * *are different keys. - The free plan allows 5 cron expressions per Cloudflare account, shared by every Worker on it. The five slots are that whole budget; only slots with a target become triggers, and a test holds the count at 5.
- A newly added cron takes up to 15 minutes to propagate, so its first slot may be missed. Existing expressions keep firing across deploys. Adding a target to a slot already in use adds no cron, so it is live on the next deploy.