Usage
Report
cronlag # repo from the git remote of the current directory
cronlag owner/repo --days 14
cronlag owner/repo -w nightly.yml -f markdown # paste into an issue
One block per scheduled workflow: the cron line(s), a strip with one glyph per slot (bar height = lag, ✕ = no run, ? = no run yet), lag min/median/p90/max, on-time and within-1-hour counts, a verdict, and the cause checks with the fix to apply.
Org-wide scan
cronlag org my-org
cronlag --org my-org --days 14 --fail-on late
cronlag org my-org -f markdown
Lists every non-archived repo in the org, finds those with on: schedule workflows, runs the same report pipeline on each (in --lite mode for fewer API calls), and prints a compact summary sorted worst first. Text, JSON and Markdown are supported; --fail-on exits 1 if any scanned repo hits the threshold. Progress goes to stderr when the terminal is a TTY. Archived repos are skipped; non-archived forks are still scanned (the fork cause check explains why schedules never fire).
Catch-up
cronlag catchup owner/repo # dry run: what would be dispatched
cronlag catchup owner/repo --dispatch # do it
For each scheduled workflow, cronlag takes the latest slot older than the grace period (--grace, default 15m) and younger than 24h. If neither a scheduled run nor a workflow_dispatch run was created after it, --dispatch starts the workflow on the default branch. Running it again does nothing until the next slot is due, so call it every 10–15 minutes from a scheduler that isn't GitHub's:
# crontab on any server
*/10 * * * * GITHUB_TOKEN=github_pat_… npx -y github:Blackman99/cronlag catchup you/repo --dispatch >> ~/cronlag.log 2>&1
The workflow must have a workflow_dispatch: trigger.
Guard: no double runs
When GitHub's late scheduled run finally arrives after catch-up already ran that slot, the guard tells it to skip:
jobs:
guard:
runs-on: ubuntu-latest
permissions: { actions: read, contents: read }
outputs: { skip: ${{ steps.g.outputs.skip }} }
steps:
- id: g
uses: Blackman99/cronlag@v0
with: { mode: guard }
work:
needs: guard
if: needs.guard.outputs.skip != 'true'
runs-on: ubuntu-latest
steps:
- run: echo "the real job"
On non-schedule events the guard always says skip=false. It also outputs slot and lag-minutes for this run.
Explain a cron line
cronlag explain "0 */6 * * *"
cronlag explain "30 5 * * 1-5" --tz America/New_York -n 10
GitHub Action (report)
on:
schedule: [{ cron: "41 7 * * 1" }] # weekly; yes, a schedule — it reports on all the others
workflow_dispatch:
permissions: { actions: read, contents: read }
jobs:
lag:
runs-on: ubuntu-latest
steps:
- uses: Blackman99/cronlag@v0
with:
days: 7
fail-on: dropped
Writes the Markdown report to the job summary and sets outputs verdict, missed, median-lag-minutes, json.
MCP server
{
"mcpServers": {
"cronlag": { "command": "npx", "args": ["-y", "github:Blackman99/cronlag", "mcp"], "env": { "GITHUB_TOKEN": "…" } }
}
}
Tools (all read-only): schedule_report {repo, days?, workflow?}, catchup_plan {repo, grace_minutes?}, cron_explain {cron, timezone?, count?}. The plan never dispatches; an agent that should act runs cronlag catchup --dispatch itself, under whatever approval you require.