Operational Reference: GitHub Actions Limits & Quotas

This document covers the GitHub Actions limits that affect fork-sync-all, what consumes them, how to detect exhaustion, and how to recover.


GitHub API Rate Limit

Quota: 5,000 requests/hour per authenticated user token.

Resets: Top of every hour (rolling window).

What consumes it:

OperationCost
gh api / REST API call1 req
Listing workflow runs1 req per page
Cancelling a run1 req
Triggering a workflow dispatch1 req
Checking job status1 req per job
GraphQL querySeparate quota (5,000 points/hr) — unaffected by REST exhaustion

How fork-sync-all burns it:

  • Every workflow_run trigger fires a new run, which itself may call the API
  • rate-limit-rerun.yml (formerly hourly) scans all recent failed runs
  • stuck-run-detector.yml (formerly hourly) lists all queued/in-progress runs
  • translate-readmes.yml was triggering after 10 workflows — each trigger consumed dozens of API calls for the org scan
  • Bulk-cancelling queued runs during cleanup consumes ~1 req per cancel — if the queue is large and quota is already low, the cancel loop itself can exhaust the remaining quota

Detecting exhaustion:

gh api rate_limit --jq '.resources.core | "remaining: \(.remaining)/\(.limit)  resets: \(.reset | todate)"'

Recovery: Wait until the top of the next hour. GraphQL remains available during REST exhaustion and can be used for read-only queries.


GitHub Actions Runner Minutes

Free tier: 2,000 minutes/month. Resets on your billing cycle date (the day of the month your GitHub account was created — check Settings → Billing → Actions for the exact date).

Paid: Billed per minute beyond the free tier; Linux runners cost 1×, Windows 2×, macOS 10×. All workflows in this repo use ubuntu-latest (Linux, 1×).

What counts against the monthly quota:

  • Every job that runs on ubuntu-latest (GitHub-hosted runner)
  • Time is measured from job start to job end, rounded up to the nearest minute
  • Jobs that are queued but never start do not consume minutes
  • Jobs that exit immediately (e.g. if: condition is false at the job level) still consume ~1 minute for runner provisioning

What does NOT count:

  • workflow_dispatch triggers that are never clicked
  • Runs that are cancelled before a job starts
  • Skipped jobs (if: evaluated to false before the runner is assigned)
  • Self-hosted runners (zero cost regardless of usage)

How fork-sync-all was burning minutes (before May 2026 fixes):

  1. mirror-orgs-watchdog fired after every mirror completion (5 workflows × hourly cadence = ~120 runs/day), each consuming ~1 min even on success
  2. update-readmes triggered after 7 workflows including high-frequency syncs
  3. inject-badges triggered after mirror workflows that run hourly
  4. stuck-run-detector and rate-limit-rerun ran hourly as meta-workflows, each consuming minutes to manage other workflows
  5. workflow_run listeners fired on every completed event (success, failure, cancelled) — not just on the outcomes they actually needed

Detecting exhaustion:

Symptoms (in order of appearance):

  1. ubuntu-latest jobs queue but never start
  2. No in-progress runs despite many queued
  3. Runs queued for hours with 0 runners active
  4. Billing API returns 404 (needs user OAuth scope — check web UI instead)

Check via GitHub web UI: Settings → Billing → Actions.

Recovery: Wait until the billing cycle reset date. In the meantime:

  • Cancel all queued runs (they will never start)
  • Do not push commits that trigger new workflow runs
  • Use workflow_dispatch manually only for critical operations

Concurrency Groups & Stuck Runs

How they work: A concurrency group allows only one run at a time for a given key. If cancel-in-progress: false, a second run queues behind the first. If the first run never finishes (e.g. runner minutes exhausted mid-job), the queued run is permanently stuck.

The cascade pattern:

  1. Runner minutes exhaust mid-job → job hangs in in_progress
  2. Next scheduled run queues behind it (cancel-in-progress: false)
  3. The in-progress run never finishes → queue grows indefinitely
  4. API calls to cancel are themselves rate-limited → nothing can be cleared

Orphaned runs: A run can become permanently orphaned if it was triggered from an older version of a workflow file that contained a job (e.g. Update cost profile) that no longer exists in the current file. The run accepts cancel API calls but GitHub immediately re-queues it because the concurrency group from the old code is still technically active. These runs time out automatically after GitHub's maximum queue wait (~6 hours). New runs from the same workflow are not blocked — they use the current file.

Policy in this repo (May 2026): All workflows use cancel-in-progress: true except those that perform multi-repo writes where mid-run cancellation would leave state partially applied:

Workflowcancel-in-progressReason
sync-templatefalsePropagates files to 35 repos — partial sync leaves repos inconsistent
mirror-releasesfalsePartial mirror leaves releases incomplete
lts-readmesfalseMid-run cancel leaves some repos un-standardised
mirror-osp-to-gitlabfalsePartial GitLab mirror
create-readmesfalseMid-run cancel leaves some repos without READMEs
mirror-artifactsfalsePartial artifact mirror
All otherstrueNewer run supersedes safely

Detecting stuck runs:

gh api "repos/Interested-Deving-1896/fork-sync-all/actions/runs?per_page=100" \
  --jq '[.workflow_runs[] | select(.status == "queued")] | length'

Bulk cancel (check quota first — cancel loop consumes ~1 req per run):

gh api rate_limit --jq '.resources.core.remaining'

gh api "repos/Interested-Deving-1896/fork-sync-all/actions/runs?per_page=100" \
  --jq '[.workflow_runs[] | select(.status=="queued") | .id] | .[]' | \
  xargs -I{} gh api -X POST \
    "repos/Interested-Deving-1896/fork-sync-all/actions/runs/{}/cancel"

workflow_run Trigger Cost Model

workflow_run fires on every completed event regardless of conclusion (success, failure, cancelled, skipped). A listener that only needs to act on failures still consumes a runner minute for every successful upstream run unless gated at the job level.

Pattern used in this repo:

# For workflows that act on upstream SUCCESS (content processors):
jobs:
  my-job:
    if: |
      github.event_name != 'workflow_run' ||
      github.event.workflow_run.conclusion == 'success'

# For workflows that act on upstream FAILURE (watchdogs/retriers):
jobs:
  retry:
    if: |
      github.event_name == 'workflow_dispatch' ||
      github.event.workflow_run.conclusion == 'failure'

This exits immediately (no runner cost) when the conclusion doesn't match, while keeping the trigger automatic.

All workflow_run listeners and their gates (May 2026):

WorkflowGate
mirror-orgs-watchdogconclusion == 'failure'
create-readmesconclusion == 'success'
inject-badgesconclusion == 'success'
lts-readmesconclusion == 'success'
mirror-osp-to-gitlabconclusion == 'success'
translate-readmesconclusion == 'success' (on gate job)
update-readmesconclusion == 'success'
dwarfs-pack-callerconclusion == 'success'
rebase-ltsconclusion == 'success'

Current Workflow Schedule Summary

Schedules as of June 2026. All times UTC (24h) / UTC (12h) / ET (EDT, UTC−4). See DOCS/workflow-scheduling.md for full per-workflow quota and window details.

Workflow24h UTC12h UTCET (EDT)CadenceNotes
mirror-to-osp:13:13 AM/PM−4hEvery 6hCore mirror chain start
mirror-osp-to-ooc:45:45 AM/PM−4hEvery 6h32 min after mirror-to-osp
sync-in:37:37 AM/PM−4hEvery 6h + daily 10:15Health check + workspace sync
auto-merge-prs:55:55 AM/PM−4hEvery 6h
queue-manager:00/:30:00/:30 AM/PM−4hEvery 30 minInfrastructure
quota-reserve:00/:30:00/:30 AM/PM−4hEvery 30 minInfrastructure
mirror-releases00:03 + 12:0312:03 AM + 12:03 PM8:03 PM + 8:03 AMEvery 12h
sync-pieroproietti-forks01:071:07 AM9:07 PMDailyReduced from 8h
mirror-osp-to-gitlab01:231:23 AM9:23 PMDailyReduced from 8h
sync-to-gitlab-variant01:501:50 AM9:50 PMDailyReduced from 8h
mirror-artifacts02:102:10 AM10:10 PMDailyReduced from 8h
mirror-orgs-full02:172:17 AM10:17 PMDaily
setup-osp-mirrors02:452:45 AM10:45 PMDailyReduced from 6h
upstream-prs03:333:33 AM11:33 PMDailyReduced from 6h
upstream-commits03:473:47 AM11:47 PMDailyReduced from 6h
git-platform-sync04:27 + 09:234:27 AM + 9:23 AM12:27 AM + 5:23 AMDaily ×2Pull + push
sync-registered-imports04:554:55 AM12:55 AMDailyReduced from 6h
sync-btrfs-devel-branches05:025:02 AM1:02 AMDailyReduced from 6h
rebase-prs05:105:10 AM1:10 AMEvery 2 daysReduced from daily
flush-lifecycleSun 06:006:00 AM Sun2:00 AM SunWeekly + manualTop-level pipeline entry point
full-chain-flush05:175:17 AM1:17 AMMonthly (1st) + via flush-lifecycleTriggered by flush-lifecycle or pre-flush-prep
reconcile-org-refs05:505:50 AM1:50 AMEvery 2 daysReduced from daily
resolve-ci07:437:43 AM3:43 AMDaily
check-ci09:059:05 AM5:05 AMDaily1,500 quota floor
check-shell-tools-ci09:309:30 AM5:30 AMDaily
inject-badges08:158:15 AM4:15 AMEvery 2 daysReduced from daily
translate-readmes10:4310:43 AM6:43 AMEvery 2 daysReduced from daily
translate-docs11:1511:15 AM7:15 AMEvery 2 daysReduced from daily
refresh-notebooklm-auth06:17 Tue6:17 AM Tue2:17 AM TueWeekly
update-infra-deps06:11 Mon6:11 AM Mon2:11 AM MonWeekly

Estimated daily drain: ~3,200 REST calls/day (~133/hr average). Worst hourly burst: ~612 calls at 03:xx UTC / 3 AM UTC / 11 PM ET. Headroom at worst hour: ~4,388 calls (well within 5,000/hr limit).

For optimal manual dispatch windows, see DOCS/workflow-scheduling.md.


To eliminate the monthly minute cap entirely, add a self-hosted runner:

  1. Go to Settings → Actions → Runners → New self-hosted runner
  2. Follow the setup instructions for your host OS
  3. Change workflow runs-on from ubuntu-latest to self-hosted (or add a label and use that label)

Self-hosted runners have no minute cost and no concurrent job cap beyond what the host machine can handle.


Quick Reference: Limit Reset Times

LimitResets
GitHub API rate limit (REST)Top of every hour
GitHub API rate limit (GraphQL)Top of every hour (separate quota)
GitHub Actions minutesBilling cycle date (check Settings → Billing)
GitHub Actions concurrent jobs (free)N/A — blocked by minute exhaustion