Runs your GitHub Actions workflows on schedule from a Linux machine you own, and tells you whether they passed.
GitHub's on: schedule is unreliable. Runs start late, sometimes by hours,
or never: some repos never get a scheduled run at all, while "Run workflow"
by hand works every time. cronward is the usual workaround done properly. A
systemd timer on an always-on machine starts each workflow through GitHub's
API (workflow_dispatch), follows the run to its end, and keeps three
answers apart:
- passed: the run cronward started for that slot ended
success. - failed: it ran and failed.
- didn't run: the slot was missed, the start was refused, the workflow was turned off, or the run was cancelled or never finished. It says why.
A check that didn't run never looks like it passed.
$ cronward status
cronward on homeserver: last tick Nov 3 06:06 (1 min ago)
nightly-check: me/weather-widget check.yml, 0 6 * * * Europe/Oslo
passed: slot Nov 3 06:00
https://github.com/me/weather-widget/actions/runs/123456789
next: Nov 4 06:00
drift-watch: me/dotfiles drift.yml, */15 * * * * UTC
didn't run: slot Nov 3 06:00, the workflow is turned off in GitHub, and cronward leaves that to you
next: Nov 3 06:15
The runs still happen on GitHub's machines. cronward is not a runner and
never runs code from a repo. It talks only to api.github.com, with a
fine-grained token per account, limited to Actions in the repos you pick.
GitHub's own docs for the
schedule event
say it "can be delayed during periods of high loads of GitHub Actions
workflow runs", that high load times include the start of every hour, and:
"If the load is sufficiently high enough, some queued jobs may be
dropped." Nothing tells you a run was dropped: the Actions tab only shows
the runs that happened.
Some schedules never fire at all, while "Run workflow" by hand works every
time. The community forum has reports of it, such as
#203880 and
#210326, with no
answer from GitHub. workflow_dispatch, which cronward uses, is the
trigger that keeps working in those reports.
You can: curl -X POST .../dispatches from cron, or a hosted cron service
(cron-job.org, a Cloudflare Worker, EventBridge) calling the same URL. That
starts the run. It doesn't tell you how the run went, and it fails quietly:
- Did it run? A refused start, an expired token or a turned-off workflow is a line in a log nobody reads. cronward follows each run to its end, and a check that didn't run is told as didn't run, never as nothing.
- Twice, or not at all. A request that times out may still have reached GitHub. Sending it again can start the run twice; not sending it can lose it. cronward saves its claim on a slot before starting it, and after an unclear answer looks for the run instead of sending another.
- The machine was off. cron skips those slots without a word. cronward runs the latest one once when it is back, and reports the others as missed.
- Where the token lives. A hosted service holds a token that can start
your workflows. cronward keeps it on your machine (encrypted from
systemd 256), and talks only to
api.github.com. - Someone has to look. cronward gives one notice when a job's result changes, and a viewer on your laptop tells you when the server itself stopped.
If a late or missing run is fine, keep GitHub's on: schedule. If you only
need the start and will look at the Actions tab yourself, the curl line is
enough.
Needs Linux with systemd and Python 3.11 or newer. Nothing else. Tried on
Arch, Debian 12 and Ubuntu 24.04. Ubuntu 22.04 has Python 3.10, too old
(its python3.11 package is a release candidate). On RHEL 9 and its kin,
dnf install python3.11 and run cronward with it: python3.11 ~/.local/bin/cronward install makes the timer use it too.
Tokens are only encrypted with systemd 256 or newer: Debian 13, Ubuntu 24.10, Fedora 41, Arch, or later. On older systems, such as Ubuntu 24.04 LTS and Debian 12, cronward works, but keeps each token as a plain file only you can read, and anything that can read your files can use it.
cronward token setandcronward doctorwarn about it. Check withsystemctl --version.
git clone https://github.com/Frestina/cronward ~/.local/share/cronward
~/.local/share/cronward/install.sh # links cronward into ~/.local/bin, and the agent skill
cronward init # writes ~/.config/cronward/config.tomlAny folder works, but cronward runs from it: keep it there, and use the same folder in the commands below.
On GitHub: Settings, Developer settings, Personal access tokens, Fine-grained tokens, Generate new token.
- Resource owner: the user or organisation that owns the repos. Each owner needs its own token, stored under its own account name.
- Repository access: only select repositories, the ones with jobs, private or public.
- Permissions: Repository permissions, Actions: Read and write. Nothing else; GitHub adds Metadata: read itself.
Then store it. It is read from stdin, checked with GitHub, and encrypted with
systemd-creds for your user on this machine. Before systemd 256 it is a
plain file only you can read instead, and token set warns you. It never
goes in the config.
cronward token set personalIt prints the GitHub user the token belongs to, so you can see it was made
under the right owner. For a name the config lacks, such as work for a
second owner, it adds [accounts.work] to the config once the token is
stored. cronward token remove work deletes the token and takes
[accounts.work] back out, unless a job still uses it; then it keeps the
account and names the jobs, which do not run without a token.
Each workflow needs workflow_dispatch: under on:, on its default
branch. Then add it:
cronward add nightly-check --repo me/weather-widget --workflow check.yml \
--schedule "0 6 * * *" # copy the cron line from the workflowadd checks with GitHub that a token reaches the repo and that the workflow
is there, then writes the job into the config. The time zone is UTC, as on
GitHub, unless you pass --timezone Europe/Oslo or set it in the config;
cronward add --help has the other options. You can also write a
[jobs.<name>] table into the config yourself: every setting is explained
in the file cronward init wrote. cronward remove nightly-check takes a
job out again.
cronward workflows personal lists the repos and workflows the token can
reach.
Once cronward runs a workflow, remove its schedule: lines, or the days
GitHub does fire give you a second run.
cronward doctor # config, tokens, repos, workflows, timer
cronward run nightly-check --wait # start one now, and wait to see it pass
cronward install # the timer: every 5 minutes, at :01, :06, ...User timers stop when you log out, unless lingering is on
(loginctl enable-linger) or a session stays logged in. cronward install
tells you which.
cd ~/.local/share/cronward && git pull --ff-only && ./install.sh
cronward install # again, so the timer gets any change to its units
cronward doctor # its first line says which cronward this isReleases are tagged vX.Y.Z with notes on what changed, and cronward --version says which one you run. On a viewer, cronward install --viewer HOST instead.
Every 5 minutes, cronward tick:
- Finds each job's due slots. If the machine was off, the latest missed slot runs once when it is back, and the others are reported as missed.
- Saves its claim on each slot first, so a slot never runs twice.
- Starts the run and keeps the run's ID. A start is only sent again when GitHub cannot have received it. After an unclear answer, cronward looks for the run it may have started instead of sending another.
- Follows open runs to the end. A run not finished after
finish_within(default 6 hours) didn't run.
New jobs start from the next slot, not the past ones. cronward tick --dry-run shows what is due and changes nothing.
GitHub turns off scheduled workflows in public repos after 60 days without
activity. Once you take out a workflow's schedule: lines, as above, that
rule no longer applies to it. If it does happen, while the lines are still
there, cronward turns the workflow back on and tells you (reenable = false to only be told). A workflow you turned off yourself is never turned
on.
They work the same as public ones, on schedule and with cronward run: the
token needs the same Actions: read and write on them, and nothing more. A
run cronward starts in a private repo spends the account's Actions minutes
the same as a run GitHub's own schedule: starts there. cronward moves the
start, not the run.
The repo has an Agent Skill, skills/cronward. In Claude Code, install it as a plugin:
/plugin install cronward --marketplace Frestina/cronward
For other agents, install.sh links it into ~/.agents/skills, and into
~/.claude/skills unless the plugin is installed. The plugin is only the
skill: the cronward command still comes from the install above, on the
machine that runs it, and on a laptop the skill reaches that machine with
cronward --on. Then you can ask the agent working in a repo to
"put check.yml on cronward, daily at 06:00". It adds workflow_dispatch:,
runs cronward add, tests the job with cronward run --wait, and takes out
the workflow's schedule: once a run has passed.
An agent never handles your token. When the token can't reach the repo yet,
it tells you where to add the repo, and waits. For an agent that does not
read skills, tell it to read skills/cronward/SKILL.md.
You get a notice when a job's result changes: to failed or didn't run, or back to passed. A job that keeps failing is one notice, not one an hour.
- A desktop notification on the machine, critical by default so it stays.
notify.command: any program, given the notice as JSON on stdin, such as a script that posts to your phone.
examples/notify-ntfy posts each notice to an ntfy topic, which the ntfy app on your phone shows. A failed or didn't run notice comes with high priority, and opens the run when you tap it. It is an example, not part of cronward: it is this script that talks to the ntfy server, one you chose.
-
In the ntfy app, subscribe to a topic with a long random name. On ntfy.sh anyone who knows the name can read it, so treat it as a password.
-
Put its settings in
~/.config/cronward/ntfy.env, which only you may read; the script refuses it otherwise:cat > ~/.config/cronward/ntfy.env <<'EOF' NTFY_TOPIC=cronward-a-long-random-name # NTFY_SERVER=https://ntfy.example.org # your own; default https://ntfy.sh # NTFY_TOKEN=tk_... # an access token, if it needs one EOF chmod 600 ~/.config/cronward/ntfy.env
-
Point the config at it, with the full path, as the command is not run by a shell:
[notify] command = ["/home/me/.local/share/cronward/examples/notify-ntfy"]
-
Check it with a notice of your own:
echo '{"title": "cronward: a test", "body": "from my machine"}' \ | ~/.local/share/cronward/examples/notify-ntfy
When the server cannot be reached, the tick's log says why
(journalctl --user -u cronward-tick), and the desktop notice still comes.
GitHub also emails the token's account about each failed run cronward started, as for any run you start, unless you turned that off (GitHub's Settings, Notifications, Actions). Those come for every failure; cronward's notice comes when the result changes, and also for didn't run, such as a missed slot or a refused start, which GitHub cannot tell you about.
If cronward runs on a server you don't look at, install a viewer on your
laptop: the same git clone and install.sh, then
cronward install --viewer homeserver # anything you would type after sshssh homeserver must work without a prompt: the host already known, and a
key file without a passphrase, as the timer runs ssh in batch mode and
never uses your ssh agent.
Every hour it reads the server's status over one ssh call and gives the same notices, plus:
- stopped ticking: the server's last tick is over 30 minutes old;
- can't be reached: for 30 minutes;
and again when that is over. The viewer needs no config and no token.
To look rather than wait for a notice, cronward status --host homeserver
shows what the viewer last fetched, and when. cronward watch homeserver
fetches it now.
To change something on the server from the laptop, put --on homeserver
first: cronward --on homeserver add ..., cronward --on homeserver token set personal, cronward --on homeserver edit. That is your normal ssh,
with your keys and agent, so it needs a login on the server that can run
commands, not only the viewer's read of the status. It runs only cronward
there.
The viewer's timer needs a key without a passphrase. To keep that key from doing anything else on the server, make one for the viewer alone, and limit it on the server to reading the status.
On the laptop:
ssh-keygen -t ed25519 -N '' -C 'cronward viewer' -f ~/.ssh/cronward_viewerOn the server, add ~/.ssh/cronward_viewer.pub to ~/.ssh/authorized_keys
with this in front of it, all on one line:
restrict,command="case $SSH_ORIGINAL_COMMAND in 'cat -- '*) exec cat .local/state/cronward/status.json ;; *) echo 'cronward: this key only reads the status' >&2; exit 1 ;; esac" ssh-ed25519 AAAA... cronward viewer
restrict turns off forwarding and a terminal. Whatever the laptop asks
for, this key gets status.json and nothing else, and anything but the
viewer's read is refused with exit 1, so cronward --on through this key
fails rather than looks done. If XDG_STATE_HOME is set on the server, put
its path in place of .local/state.
On the laptop, in ~/.ssh/config:
Host homeserver
HostName homeserver.example.org
IdentityFile ~/.ssh/cronward_viewer
The viewer's timer uses key files only, so it logs in with this key. Your
own ssh and cronward --on try your agent's keys first, so they still log
in as you when your agent holds a key the server accepts; without one, they
get this key and are refused. Leave out IdentitiesOnly yes, which keeps
the agent's keys out. The limit is only worth having if this is the one key
without a passphrase that the server takes from the laptop.
cronward init write the example config
cronward edit open the config in your editor, then check it
cronward token set|remove ACCOUNT store a token (read from stdin), or delete it;
adds or takes out [accounts.ACCOUNT]
cronward workflows [ACCOUNT] [--json]
the repos and workflows the tokens can reach
cronward add JOB --repo R --workflow W --schedule CRON
check a job with GitHub, then add it to the config
cronward remove JOB take a job out of the config
cronward doctor check config, tokens, repos, workflows and timer
cronward status [--json] each job's latest result and next slot
cronward status --host HOST [--json] the same, as a viewer last fetched it from HOST
cronward history JOB [--json] a job's last 50 results, newest first
cronward run JOB [--wait [TIME]] start a job now, and with --wait, say how it went
cronward tick [--dry-run] what the timer runs
cronward watch HOST what a viewer's timer runs
cronward install [--viewer HOST] the user timer, on this machine or a viewer
cronward uninstall remove the timers (config, tokens and state stay)
cronward --on HOST COMMAND [ARGS] run a cronward command on HOST over your ssh
cronward --version which cronward this is
status exits 1 when a job's latest result is not passed, and run --wait
when the run did not pass, for scripts.
~/.config/cronward/config.toml |
the config |
~/.local/share/cronward/tokens/ |
the tokens, encrypted from systemd 256 |
~/.local/state/cronward/state.json |
claims, run IDs, the last 50 results of each job |
~/.local/state/cronward/status.json |
what status --json shows, written every tick |
~/.local/state/cronward/status-HOST.json |
on a viewer: the status it last fetched from HOST |
The XDG_*_HOME variables move them. SPEC.md has every rule.
- What the token can do: start and read workflow runs, and turn workflows on and off, in the repos you picked. It cannot read your code, push, or see other repos.
- Where the token is kept: encrypted with
systemd-creds, so only your user on this machine can decrypt it. Before systemd 256 (Ubuntu 24.04, Debian 12) it is a plain file only you can read: a backup of your home, or any program running as you, has it. Keep the token narrow and give it an expiry date. - What cronward does with it: starts runs, reads them, and turns on a
workflow GitHub turned off for inactivity (unless
reenable = false). - Where it talks: only
https://api.github.com, fixed in the code; a redirect to another host is refused. Yournotify.command(such as the ntfy example), and the viewer's ssh and--on, are the only other traffic, and all are yours to set up. - What runs on your machine: cronward itself. Not a runner, so a pull request to a public repo can never run code here.
To report a security problem, see SECURITY.md.
MIT