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 target repos.
-
Note the App ID from the App's settings page, and the installation ID from the end of the installation's URL.
-
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_IDandGITHUB_APP_INSTALLATION_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.
- Settings -> Secrets and variables -> Actions.
CLOUDFLARE_API_TOKEN-- the token above, shown once at creation.CLOUDFLARE_ACCOUNT_ID-- from the Workers & Pages overview.
Push to main deploys. The checks run on every push without either secret.
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 cron each one runs on:
// schedules/ctan.ts
export default {
repo: "jshvn/ctan",
workflows: [{ workflow: "sync.yml", cron: "42 * * * *" }],
}schedules/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 those same cron strings in wrangler.jsonc, which is JSON and cannot
import them. task crons writes them there; task check fails until it has been run.
To add one:
- Write
schedules/<name>.tsand add its import toschedules/index.ts. task crons.- Install the App on that repo.
- 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.- 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. Targets sharing an expression share a trigger, 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.