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

Workflows that run on a schedule and their cadence after May 2026 fixes:

WorkflowScheduleNotes
mirror-to-ospHourly :00Core mirror
mirror-releasesHourly :00
mirror-artifactsHourly :08
mirror-osp-to-gitlabHourly :24
upstream-prsHourly :32
upstream-commitsHourly :40
reconcile-org-refsHourly :56
sync-pieroproietti-forksHourly :05
notify-pollerEvery 30 min
stuck-run-detectorEvery 6h :20Reduced from hourly
rate-limit-rerunEvery 6h :12Reduced from hourly
rate-limit-budget-reportDaily 11:00Reduced from every 2h
update-readmesDaily 10:20
create-readmesDaily 10:30
inject-badgesDaily 10:40
translate-readmesDaily 10:50
sync-forksDaily 11:30
mirror-orgs-fullDaily 10:00
lts-readmesMonthly 1st

Hourly workflows are the primary minute consumers. At ~1 min/run, 8 hourly workflows = ~192 min/day = ~5,760 min/month — well over the 2,000 min free tier. A paid plan or self-hosted runner is required for this repo's workload.


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