A Weekly CI Health Report for GitHub Actions

Assemble billed minutes by runner label, p95 duration, queue wait and cache hit rate into one scheduled GitHub Actions job that writes a job summary.

A weekly GitHub Actions health report is a scheduled workflow that pulls four numbers from the reports API and writes them into its own run page: billed minutes by runner label, duration percentiles per workflow and job, queue wait by runner label, and cache hit rate. It runs on a cron, needs one API key on the ci scope, and costs under a cent per run in runner minutes.

This guide covers why the four numbers resist hand assembly, which report answers each one, how to compute a p95 the dashboard does not show, and a complete workflow that writes the whole thing into a job summary. It sits under GitHub Actions observability with WarpBuild, which is the wider view of what the platform reports across an estate.

Diagnosis

Most teams do build a weekly report. It gets assembled twice, once by whoever proposed it and once by whoever was asked to keep it going, and then it stops. Three things break it.

The four numbers are four different groupings of the same week. The reports surface splits into Billing, with CI, Docker Builder and Cache tabs, plus Jobs and Queue Timings (reports documentation). CI billing returns one row per job execution. Jobs returns one row per unique repository, workflow and job name. Queue Timings returns one row per runner label and stack pair. Cache billing returns one row per cache billing entry. Reading spend and reading latency for the same seven days means reading four aggregations, and reconciling them by eye across four browser tabs is the step that nobody repeats on week three.

The percentile you want is usually not the percentile on the screen. The Jobs report exposes duration P75 and P90 and queue time P75 and P90. P90 is the right number for a weekly trend line, and it is the wrong number for an alerting threshold, because the run that makes an engineer give up and go get coffee sits further out. A p95 has to be computed from the per-execution rows, which the CI billing report already returns with an execution_time on every row.

The report lands where nobody reads it. A document in a wiki gets stale silently. A message in a channel scrolls away. The run page of the job that produced the numbers is the one place that keeps them attached to a timestamp, a commit, and a rerun button, which is what a job summary is for (GitHub workflow commands reference, checked on 2026-08-13).

One more constraint shapes the design. Every operation in the WarpBuild API is annotated alpha and every response carries an X-WarpBuild-API-Stability header, so pin only to documented behavior and log that header from the weekly job.

Fix

Pull four requests, compute two derived figures locally, and render the result as markdown.

Number in the reportRequestFields usedWhat one row is
Billed minutes and cost by runner labelGET /reports/billing/cirunner_label, billed_time, runner_cost, snapshot_cost, total_costone job execution
p95 duration per repository and jobGET /reports/billing/cirepo, job_name, execution_timeone job execution, sorted locally
Duration P75 and P90 per workflowGET /reports/jobsworkflow_name, job_name, run_count, success_rate, duration_p75, duration_p90one repository, workflow and job name
Queue wait by runner labelGET /reports/queue-timingsrunner_label, stack, stack_kind, run_count, queue_time_p75, queue_time_p90one runner label and stack pair
Cache hit rateGET /reports/billing/cachetypeone cache billing entry

Group on billed minutes. Each CI billing row carries both execution_time and billed_time. Group by runner_label and sum billed_time, because billed time is the figure that reconciles against an invoice. Keep execution_time for the latency half of the report, where billing rounding has no place.

A p95 comes from sorting rows. Group the per-execution rows by repository and job name, sort each group by execution_time, and read the value at the 95th percentile index. This is the number that answers "how long does a bad run take", and the gap between it and the P90 in the Jobs report is the width of the tail.

Workflow names need a join. CI billing rows key on repository and job name and carry no workflow name, while the Jobs report carries workflow_name with P75 and P90. When the weekly report has to show a p95 keyed by workflow, each billing row also carries run_id, which is the GitHub Actions workflow run id, so a lookup against the GitHub workflow runs API returns the workflow name for that run. Cache the mapping; a week of runs resolves to far fewer distinct run ids than job rows.

Queue wait is the number with no invoice line. Queue time is not billed, so it never appears in a cost review and it is often the largest single block of wall clock time in the week. The Queue Timings report aggregates it per runner label and stack, and its daily chart splits total queue time into GitHub time and WarpBuild time, which separates a plan concurrency ceiling from runner startup.

Cache hit rate from entry types. The Cache tab filters entries by storage, operation-hit, and operation-commit. Count the rows of the two operation types in the export and divide hits by hits plus commits. Two caveats belong in the report footnote: a restore-keys prefix match still restores data and still bills as an operation, so this ratio measures how often a job found something rather than how often the exact key matched, and WarpBuild caching is not supported on Windows runners (caching documentation), so Windows jobs contribute no rows either way.

Configuration

Create the key on the API keys page in dashboard settings and grant it the ci scope. The value starts with wkey- and is shown once, so write it into the repository or organization secret in the same step that creates it. Automation patterns for the rest of the API are in the automation documentation. Every request below sends Authorization: Bearer wkey-xxxx and takes start_date and end_date as RFC3339 timestamps.

name: weekly-actions-health-report

on:
  schedule:
    - cron: "23 7 * * 1"
  workflow_dispatch:

permissions:
  contents: read

jobs:
  report:
    runs-on: warp-ubuntu-latest-x64-2x
    timeout-minutes: 10
    env:
      API: https://api.warpbuild.com/api/v1
      KEY: ${{ secrets.WARPBUILD_API_KEY }}
    steps:
      - name: Compute the window
        id: window
        run: |
          echo "start=$(date -u -d '7 days ago' +%Y-%m-%dT00:00:00Z)" >> "$GITHUB_OUTPUT"
          echo "end=$(date -u +%Y-%m-%dT00:00:00Z)" >> "$GITHUB_OUTPUT"

      - name: Pull the four reports
        env:
          START: ${{ steps.window.outputs.start }}
          END: ${{ steps.window.outputs.end }}
        run: |
          set -euo pipefail
          pull() {
            curl -sS -D headers-"$1".txt -G "$API/$2" \
              -H "Authorization: Bearer $KEY" \
              --data-urlencode "start_date=$START" \
              --data-urlencode "end_date=$END" \
              "${@:3}" -o "$1"
          }
          pull ci.json      reports/billing/ci        --data-urlencode 'per_page=200'
          pull jobs.json    reports/jobs              --data-urlencode 'per_page=50' \
                                                      --data-urlencode 'sort_by=duration_p90' \
                                                      --data-urlencode 'sort_order=desc'
          pull queue.json   reports/queue-timings     --data-urlencode 'per_page=50'
          pull cache.csv    reports/billing/cache     --data-urlencode 'format=csv'
          grep -i '^x-warpbuild-api-stability' headers-*.txt || true

      - name: Write the job summary
        run: |
          set -euo pipefail
          {
            echo "## GitHub Actions health, week ending ${{ steps.window.outputs.end }}"
            echo
            echo "### Billed minutes by runner label"
            echo "| Runner label | Jobs | Billed minutes | Cost |"
            echo "| --- | --- | --- | --- |"
            jq -r '.jobs.items | group_by(.runner_label)[]
              | [ .[0].runner_label,
                  length,
                  ((map(.billed_time) | add) / 60 | round),
                  ((map(.total_cost) | add) * 100 | round / 100) ]
              | "| \(.[0]) | \(.[1]) | \(.[2]) | $\(.[3]) |"' ci.json
            echo
            echo "### Slowest jobs by p95 execution time (minutes)"
            echo "| Repository | Job | Runs | p95 |"
            echo "| --- | --- | --- | --- |"
            jq -r '.jobs.items | group_by(.repo + "|" + .job_name)[]
              | (map(.execution_time) | sort) as $d
              | [ .[0].repo, .[0].job_name, ($d | length),
                  (($d[ ((($d | length) - 1) * 0.95) | floor ]) / 60 * 10 | round / 10) ]
              | select(.[2] >= 20)
              | "| \(.[0]) | \(.[1]) | \(.[2]) | \(.[3]) |"' ci.json \
              | sort -t'|' -k5 -rn | head -10
            echo
            echo "### Duration P90 by workflow"
            echo "| Repository | Workflow | Job | Runs | Success | P90 |"
            echo "| --- | --- | --- | --- | --- | --- |"
            jq -r '.table.items[]
              | "| \(.repo) | \(.workflow_name) | \(.job_name) | \(.run_count) | \(.success_rate) | \(.duration_p90) |"' \
              jobs.json | head -10
            echo
            echo "### Queue wait by runner label"
            echo "| Runner label | Stack | Jobs | Queue P90 |"
            echo "| --- | --- | --- | --- |"
            jq -r '.table.items[]
              | "| \(.runner_label) | \(.stack_kind) | \(.run_count) | \(.queue_time_p90) |"' queue.json
            echo
            awk -F, '
              NR == 1 { for (i = 1; i <= NF; i++) col[$i] = i; next }
              { count[$col["type"]]++ }
              END {
                h = count["operation-hit"] + 0
                w = count["operation-commit"] + 0
                if (h + w > 0)
                  printf "### Cache hit rate: %.1f percent (%d hits, %d writes)\n", 100 * h / (h + w), h, w
              }' cache.csv
          } >> "$GITHUB_STEP_SUMMARY"

      - uses: actions/upload-artifact@v4
        with:
          name: actions-health-report
          path: |
            ci.json
            jobs.json
            queue.json
            cache.csv

Five details carry the design.

  • The cron minute is 23 rather than 0, because GitHub names the start of every hour as a high load window for the schedule event and the run can start late (events that trigger workflows, checked on 2026-08-13).
  • The window boundaries are UTC midnight on both ends, so two consecutive Mondays neither overlap nor leave a gap.
  • per_page is set to the maximum each report allows, 200 on CI billing and 50 on the other two. A busy organization exceeds 200 job rows in a week, so walk page until jobs.next stops advancing before this goes into production.
  • Cache billing is pulled as CSV rather than JSON, because the export contains every row matching the filters instead of one page, and counting entry types needs all of them.
  • Each step writing to GITHUB_STEP_SUMMARY is limited to 1MiB, and a step that exceeds it fails its upload with an error annotation while the job conclusion stays unchanged. The head -10 on the two long tables keeps the panel inside that budget, and the raw files go to an artifact for anyone who wants the full set. The job summary reference covers the rest of the rules.

To pull the same window into a spreadsheet or a warehouse instead of a run page, add format=csv to any of the four requests; the CSV export answer covers what each export is keyed on.

Cost or Time Model

Rates below come from the WarpBuild pricing page, checked on 2026-08-13, and billing is per minute.

Runner labelvCPURAMUSD per minute
warp-ubuntu-latest-x64-2x28 GB$0.004
warp-ubuntu-latest-x64-4x416 GB$0.008
warp-ubuntu-latest-x64-8x832 GB$0.016
warp-ubuntu-latest-arm64-4x416 GB$0.006
warp-macos-15-arm64-6x622 GB$0.08
warp-windows-latest-x64-4x416 GB$0.016

Assumptions. One organization, one week, 4,200 job executions, with snapshot restores on 320 of them, cache operations counted from the export, and billed minutes grouped by runner label. Substitute your own pull; the arithmetic is what matters.

Runner labelJobsBilled minutesRateCost
warp-ubuntu-latest-x64-4x2,1409,400$0.008$75.20
warp-ubuntu-latest-x64-8x6203,150$0.016$50.40
warp-ubuntu-latest-arm64-4x9802,600$0.006$15.60
warp-macos-15-arm64-6x180540$0.08$43.20
warp-windows-latest-x64-4x280800$0.016$12.80

Runner cost for the week is $75.20 + $50.40 + $15.60 + $43.20 + $12.80 = $197.20. The 320 snapshot restores add 320 x $0.04 = $12.80. The cache export counted 21,400 rows typed operation-hit and 3,900 typed operation-commit, which is 25,300 operations at $0.0001 each, or $2.53, and a hit rate of 21,400 / 25,300 = 84.6 percent. The weekly total is $212.53. Cache storage bills separately at $0.20 per GB-month, so 40 GB held across the month is $8.00 and belongs in the monthly view rather than the weekly one.

Two derived figures make that table useful to someone outside the platform team. Against 84 pull requests merged in the same week, the estate costs $212.53 / 84 = $2.53 per merged pull request. Held flat, the week annualizes to $212.53 x 52 = $11,051.56.

Now price the report itself. One job on warp-ubuntu-latest-x64-2x finishing in three minutes is 3 x $0.004 = $0.012 per run, or $0.62 for a year of Mondays. That is the whole cost line for the workflow above.

The number the report surfaces that no invoice contains is queue wait. At a queue time P90 of 30 seconds across 4,200 jobs, the week holds 4,200 x 30 = 126,000 seconds, or 35 hours, of wall clock time that costs nothing and delays every one of those jobs. Tracking that figure week over week is what turns the report into a target, which is the subject of CI SLOs and build time budgets.

FAQ

What belongs in a weekly GitHub Actions health report?

Four numbers cover the ground: billed minutes and cost grouped by runner label, duration percentiles per repository, workflow and job, queue wait per runner label and stack, and cache hit rate. Each one comes from a different report because each one aggregates differently, which is why the weekly job pulls four requests rather than one. The mapping from number to endpoint and field is in the table above.

Where does a p95 come from when the Jobs report shows P75 and P90?

Compute it from the per-execution rows. The Jobs report aggregates to one row per repository, workflow and job name with duration P75 and P90, while the CI billing report returns one row per job execution with an execution_time on each row. Sort those rows inside a group and take the value at the 95th percentile index, which is what the jq block in the workflow above does. For the same figure keyed by workflow, join on run_id against the GitHub workflow runs API.

How is cache hit rate measured from the reports API?

The Cache tab of the Billing report types every entry as storage, operation-hit, or operation-commit. Export the window as CSV, count rows of the two operation types, and divide hits by hits plus commits. Two things shape the reading: a restore-keys prefix match restores data and bills as an operation, so the ratio counts restores that found something rather than exact key matches, and Windows jobs contribute no rows because WarpBuild caching is not supported on Windows runners.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.