How to Measure DORA Metrics From GitHub: gh api and SQL (2026)
This guide is for an engineer who wants real DORA numbers for one service without buying a platform first. By the end you will have a script that pulls production deployments, deployment statuses, merged pull requests and the commits each deploy shipped from GitHub, a SQLite database, and queries for all five DORA metrics: deployment frequency, change lead time, change fail rate, deployment rework rate and failed deployment recovery time. I tested every command on 31 October 2026 with gh 2.49 and SQLite 3.54 against a public repository, using REST API version 2022-11-28 (supported until March 2028 per GitHub's API versions page). You need the gh CLI logged in with read access to the repo, jq, sqlite3, and deploys that run through GitHub Actions or call the Deployments API. If you want the background on what the five metrics mean and how they get gamed, read the companion post, DORA metrics explained, first.
Step 1: Make every production deploy a GitHub deployment
GitHub only knows about deploys it is told about. The easy path is GitHub Actions: "when a workflow job references an environment, GitHub creates a deployment object to track the deployment", per the deployment control docs. Put the environment on the job that actually ships to production, and only that job.
# .github/workflows/deploy.yml
name: deploy
on:
push:
branches: [main]
concurrency: production # one production deploy at a time
jobs:
build:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7.0.1
- run: make build test
deploy:
needs: build
runs-on: ubuntu-24.04
environment:
name: production # creates a deployment object for this SHA
url: ${{ steps.release.outputs.url }}
steps:
- uses: actions/checkout@v7.0.1
- id: release
run: ./scripts/deploy.sh # must print url=... to $GITHUB_OUTPUT
smoke-test:
needs: deploy
runs-on: ubuntu-24.04
environment:
name: production
deployment: false # reuse the environment's secrets, no extra deployment
steps:
- run: ./scripts/smoke.sh
The deployment: false key matters. Without it, any other job that needs production secrets also creates a deployment and inflates your frequency. The same applies to matrix jobs: each leg that references the environment gets its own deployment object.
If you deploy from Argo CD, Spinnaker or a shell script instead, call Create a deployment (POST /repos/{owner}/{repo}/deployments, only ref is required) and then post statuses as the rollout progresses.
# Record a deploy made outside Actions, then mark it successful.
DEPLOY_ID=$(gh api -X POST repos/OWNER/REPO/deployments \
-f ref="$GIT_SHA" -f environment=production -F auto_merge=false \
-F required_contexts[] --jq .id)
gh api -X POST "repos/OWNER/REPO/deployments/$DEPLOY_ID/statuses" \
-f state=success -f environment_url="https://app.example.com"
-F required_contexts[] sends an empty array, which the docs say bypasses status checks entirely; otherwise GitHub returns 409 Conflict until the required contexts report success.
Step 2: List deployments and their latest status with gh api
The List deployments endpoint filters by environment, sha, ref and task, at up to 100 per page. A deployment object is created before the rollout, so you also need its statuses, from GET /repos/{owner}/{repo}/deployments/{deployment_id}/statuses (docs). Here is real output from cli/cli's public production environment:
gh api "repos/cli/cli/deployments?environment=production&per_page=5" \
--jq '.[] | [.id, .sha[0:7], .ref, .created_at] | @tsv'
7341214910 63837fb trunk 2026-10-26T19:15:31Z
7341083527 63837fb trunk 2026-10-26T19:09:27Z
7341083521 63837fb trunk 2026-10-26T19:09:27Z
7341083509 63837fb trunk 2026-10-26T19:09:27Z
7287944018 4f2cfe9 trunk 2026-10-23T20:19:52Z
Four deployments, one SHA. Three were created in the same second, which is a matrix, and the fourth came six minutes later from another run. All four reached success. One release, not four. The statuses come back newest first:
gh api repos/cli/cli/deployments/7341214910/statuses \
--jq '.[] | [.state, .created_at] | @tsv'
success 2026-10-26T19:20:32Z
in_progress 2026-10-26T19:16:19Z
queued 2026-10-26T19:16:16Z
waiting 2026-10-26T19:15:32Z
waiting 2026-10-26T19:15:32Z
Use the success status time as "deployed at", not the deployment's created_at, which here is five minutes earlier and includes a wait for approval. Save the first part of the collection script as collect.sh:
#!/usr/bin/env bash
# collect.sh OWNER/REPO ENVIRONMENT [MAX_DEPLOYMENTS] (writes TSV files to the current directory)
set -euo pipefail
REPO="$1"; ENV="$2"; MAX="${3:-100}"
H=(-H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28")
# Newest MAX (<=100) deployments for one environment. Add --paginate for more history.
gh api "${H[@]}" "repos/$REPO/deployments?environment=$ENV&per_page=$MAX" \
--jq '.[] | [.id, .sha, .created_at] | @tsv' > deployments.tsv
# Latest status per deployment (newest status is returned first).
: > statuses.tsv
while IFS=$'\t' read -r id sha created; do
gh api "${H[@]}" "repos/$REPO/deployments/$id/statuses?per_page=1" \
--jq ".[0] // empty | [$id, .state, .created_at] | @tsv" >> statuses.tsv
done < deployments.tsv
Step 3: Map merged PRs to the deploy that shipped them
Lead time needs to know which deploy first carried each change. There are two API routes. List pull requests associated with a commit (GET /repos/{owner}/{repo}/commits/{commit_sha}/pulls) works per commit, which costs one call per commit. Cheaper: for each pair of consecutive successful deploys, call Compare two commits with BASE...HEAD to get the commits in between, then match them against the merge commits of merged PRs. Append this to collect.sh:
# Merged PRs: number, merged_at, merge commit, labels, title.
gh pr list -R "$REPO" --state merged --limit 500 \
--json number,mergedAt,mergeCommit,labels,title \
--jq '.[] | [.number, .mergedAt, .mergeCommit.oid,
([.labels[].name] | join(",")), .title] | @tsv' > prs.tsv
# Successful deploys, deduplicated by SHA (earliest success wins), oldest first.
join -t $'\t' <(sort deployments.tsv) <(sort statuses.tsv) \
| awk -F'\t' '$4=="success"{print $5"\t"$2}' | sort | awk -F'\t' '!seen[$2]++' > deployed.tsv
# Commits shipped by each deploy: compare previous deployed SHA...this SHA.
: > shipped.tsv
prev=""
while IFS=$'\t' read -r deployed_at sha; do
if [[ -n "$prev" && "$prev" != "$sha" ]]; then
gh api "${H[@]}" --paginate "repos/$REPO/compare/$prev...$sha?per_page=100" \
--jq ".commits[] | [\"$sha\", \"$deployed_at\", .sha] | @tsv" >> shipped.tsv
fi
prev="$sha"
done < deployed.tsv
wc -l deployments.tsv statuses.tsv prs.tsv deployed.tsv shipped.tsv
Run it:
bash collect.sh cli/cli production 40
40 deployments.tsv
40 statuses.tsv
500 prs.tsv
9 deployed.tsv
499 shipped.tsv
1088 total
Forty deployment objects collapsed to nine distinct successful releases. That ratio is the single most common reason a home-grown DORA dashboard reports a deployment frequency several times higher than reality. The run took about a minute and roughly 55 API calls, well inside the authenticated REST limits.
Two limits to know. Without paging, compare returns at most 250 commits, which is why the script pages. And the oldest deploy in the window has no predecessor, so its commits are not attributed; pull a few more deployments than the period you report.
Step 4: Load into SQLite and compute lead time and deployment frequency
DORA defines lead time from commit to production. This guide measures from PR merge, because merge time is unambiguous and the time before merge is already visible in your PR tooling; say so on the dashboard. Save as dora.sql:
-- dora.sql: run with sqlite3 dora.db < dora.sql (SQLite 3.25+ for window functions)
.mode tabs
CREATE TABLE IF NOT EXISTS deployments(id INTEGER PRIMARY KEY, sha TEXT, created_at TEXT);
CREATE TABLE IF NOT EXISTS statuses(deployment_id INTEGER, state TEXT, at TEXT);
CREATE TABLE IF NOT EXISTS prs(number INTEGER, merged_at TEXT, merge_sha TEXT, labels TEXT, title TEXT);
CREATE TABLE IF NOT EXISTS deployed(deployed_at TEXT, sha TEXT);
CREATE TABLE IF NOT EXISTS shipped(deploy_sha TEXT, deployed_at TEXT, commit_sha TEXT);
DELETE FROM deployments; DELETE FROM statuses; DELETE FROM prs; DELETE FROM deployed; DELETE FROM shipped;
.import deployments.tsv deployments
.import statuses.tsv statuses
.import prs.tsv prs
.import deployed.tsv deployed
.import shipped.tsv shipped
.mode box
-- Lead time for changes: PR merge to the first successful deploy containing it.
WITH lt AS (
SELECT p.number,
(julianday(MIN(s.deployed_at)) - julianday(p.merged_at)) * 24.0 AS hours
FROM prs p JOIN shipped s ON s.commit_sha = p.merge_sha
GROUP BY p.number
), ranked AS (
SELECT hours, ROW_NUMBER() OVER (ORDER BY hours) AS rn, COUNT(*) OVER () AS n FROM lt
)
SELECT n AS prs_shipped,
ROUND(MAX(CASE WHEN rn <= (n + 1) / 2 THEN hours END), 1) AS median_hours,
ROUND(MAX(CASE WHEN rn <= (n * 9 + 9) / 10 THEN hours END), 1) AS p90_hours
FROM ranked;
-- Deployment frequency: distinct successful deploys per week.
SELECT strftime('%Y-W%W', deployed_at) AS week, COUNT(*) AS deploys
FROM deployed GROUP BY week ORDER BY week;
Output on the cli/cli data:
╭─────────────┬──────────────┬───────────╮
│ prs_shipped │ median_hours │ p90_hours │
╞═════════════╪══════════════╪═══════════╡
│ 139 │ 120.2 │ 264.0 │
╰─────────────┴──────────────┴───────────╯
╭──────────┬─────────╮
│ week │ deploys │
╞══════════╪═════════╡
│ 2026-W35 │ 2 │
│ 2026-W37 │ 1 │
│ 2026-W39 │ 1 │
│ 2026-W42 │ 4 │
│ 2026-W43 │ 1 │
╰──────────┴─────────╯
Do not read that as the GitHub CLI team's DORA score. Their production environment is a release workflow for a CLI that users install, so "deploy" means "release", and weeks with no release simply do not appear. That is exactly the conversation to have about your own data before anyone puts it on a slide. Report the median and p90, not the mean.
Step 5: Change fail rate and rework rate from reverts, hotfix and incident labels
GitHub has no notion of a failed change, so you have to create one. The lowest-friction convention I know: a PR that fixes a production problem caused by a recent deploy gets a hotfix label, an incident gets an issue with an incident label, and git revert titles stay as GitHub generates them ("Revert ..."). Create the labels once per repo:
gh label create incident --color B60205 --description "Production incident (DORA)"
gh label create hotfix --color D93F0B --description "Unplanned fix for a production problem (DORA rework)"
Then add this query to dora.sql. A deploy counts as failed when the next deploy ships a revert or a hotfix; the next deploys themselves are rework:
-- Change fail rate and deployment rework rate.
WITH d AS (
SELECT sha, deployed_at, LEAD(sha) OVER (ORDER BY deployed_at) AS next_sha FROM deployed
), fix_deploys AS (
SELECT DISTINCT s.deploy_sha
FROM shipped s JOIN prs p ON p.merge_sha = s.commit_sha
WHERE p.labels LIKE '%incident%' OR p.labels LIKE '%hotfix%' OR p.title LIKE 'Revert %'
)
SELECT COUNT(*) AS deploys,
SUM(next_sha IN (SELECT deploy_sha FROM fix_deploys)) AS followed_by_fix,
ROUND(100.0 * SUM(next_sha IN (SELECT deploy_sha FROM fix_deploys)) / COUNT(*), 1) AS change_fail_pct,
ROUND(100.0 * (SELECT COUNT(*) FROM fix_deploys) / COUNT(*), 1) AS rework_pct
FROM d;
╭─────────┬─────────────────┬─────────────────┬────────────╮
│ deploys │ followed_by_fix │ change_fail_pct │ rework_pct │
╞═════════╪═════════════════╪═════════════════╪════════════╡
│ 9 │ 1 │ 11.1 │ 11.1 │
╰─────────┴─────────────────┴─────────────────┴────────────╯
The one hit was a PR titled Revert "Use internal Apple codesign action", a build tooling revert rather than a user-facing failure. Nine deploys is too small a sample for a percentage to mean much, and the query cannot tell a revert of a broken release from a revert of an unmerged experiment. This heuristic undercounts failures fixed by roll-forward without a label and overcounts tidy-up reverts. Labels applied during the incident are the only thing that makes it accurate, which is why the labels come before the dashboard.
Step 6: Failed deployment recovery time from incident issues
DORA's current definition is the time to recover from a deployment that failed and required immediate intervention, so outages you did not cause are out of scope. Two workable measurements from GitHub: the incident issue's open-to-close time, or the time from the failed deploy to the next successful deploy. Pull incident issues with:
gh issue list -R OWNER/REPO --label incident --state all --limit 500 \
--json number,createdAt,closedAt,labels \
--jq '.[] | [.number, .createdAt, (.closedAt // ""), ([.labels[].name] | join(","))] | @tsv' > incidents.tsv
-- Append to dora.sql
CREATE TABLE IF NOT EXISTS incidents(number INTEGER, opened_at TEXT, closed_at TEXT, labels TEXT);
DELETE FROM incidents;
.mode tabs
.import incidents.tsv incidents
.mode box
SELECT COUNT(*) AS incidents,
ROUND(AVG((julianday(closed_at) - julianday(opened_at)) * 24), 1) AS mean_hours,
ROUND(MAX((julianday(closed_at) - julianday(opened_at)) * 24), 1) AS worst_hours
FROM incidents WHERE closed_at <> '';
Close the issue when users are fine, not when someone starts the rollback. If your incidents live in PagerDuty or incident.io rather than GitHub, export the same three columns and keep the SQL. For the process side, see my post on incident management in 2026.
Step 7: Dashboards with Apache DevLake (and what happened to Four Keys)
Google's Four Keys project was the reference implementation for years. It was archived on 23 January 2024 and its README says it "is not currently maintained", so do not start there in 2026. The maintained open-source option is Apache DevLake, which the ASF announced as a top-level project in October 2025. Its latest stable release is v1.0.2 (2 July 2025); the v1.0.3 line has shipped betas through v1.0.3-beta18 on 27 September 2026 (gh api repos/apache/devlake/releases).
# Install DevLake v1.0.2 with Docker Compose (per the DevLake setup docs).
mkdir devlake && cd devlake
gh release download v1.0.2 -R apache/devlake -p docker-compose.yml -p env.example
mv env.example .env
# Generate ENCRYPTION_SECRET and paste it into .env (keep it: losing it means losing stored tokens).
openssl rand -base64 2000 | tr -dc 'A-Z' | fold -w 128 | head -n 1
docker compose up -d
# Config UI: http://localhost:4000 Grafana: http://localhost:3002 (admin/admin, change it)
In the config UI, add a GitHub connection, set the deployment pattern (the docs' example regex is (?i)(deploy|push-image) against workflow runs; since v0.20 GitHub deployments are collected by default), map incident issues, enable DORA metrics on the project and run Collect All. The built-in DORA Grafana dashboard then shows four metrics. Know its definitions before you trust it: per the lead time docs, lead time is the median PR cycle time from the PR's first commit to deployment, and change failure rate is deployments linked to incidents over total deployments. The DORA page I read describes recovery time but does not mention rework rate, so you still need the Step 5 query for the fifth metric.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Deployment frequency is several times too high | Matrix legs, re-runs or secret-only jobs each create a deployment | Deduplicate by SHA on success status; set deployment: false on non-deploy jobs |
List deployments returns [] |
Environment name mismatch (names are not case sensitive, but prod is not production) or deploys not created |
gh api repos/OWNER/REPO/environments --jq '.environments[].name' and use the exact name |
| Lead time is negative or zero | Using deployment created_at, or a deploy of an older SHA after a newer one |
Use the success status time; take MIN(deployed_at) per PR |
| Most PRs never match a deploy | Squash or rebase merges where mergeCommit is not on the deployed branch, or deploys from tags |
Compare tags instead of SHAs, or call GET /commits/{sha}/pulls per commit |
| One deploy "ships" hundreds of commits | Previous SHA is not an ancestor (release branch, force push), so compare uses the merge base | Restrict to one branch with ref=main, or exclude deploys whose compare status is diverged |
HTTP 409 creating a deployment |
Required commit status checks have not passed, or an auto-merge conflict | Wait for checks, or send an empty required_contexts[] deliberately |
HTTP 403 or rate limit exceeded |
Hammering statuses or compare across a long history | Cache TSVs, collect incrementally, use a GitHub App token; search endpoints allow only 30 requests a minute |
| Change fail rate is 0% for months | Nobody labels hotfixes or incidents | Make the label part of the incident template and the hotfix PR template |
| DevLake dashboard empty | Deployment regex does not match job names, or DORA not enabled on the project | Check the scope config pattern and the project's Enable DORA metrics toggle, then Collect All again |
FAQ
Can GitHub calculate DORA metrics natively?
Not as a product feature on github.com. GitHub stores the raw events (deployments, statuses, PRs, issues) and exposes them through the REST API, but it does not compute the five DORA metrics for you. You either query them yourself as in this guide or use a tool such as Apache DevLake.
Should lead time start at first commit or at PR merge?
DORA defines it from commit to production, and DevLake starts at the PR's first commit. Starting at merge gives cleaner data and isolates the release pipeline. Pick one, label the dashboard with it, and track PR review time separately so the queue before merge stays visible.
Is Google Four Keys still maintained?
No. The dora-team/fourkeys repository was archived on 23 January 2024 and is read-only; its README encourages forking it or using it as inspiration. Apache DevLake is the actively developed open-source alternative.
How many deployments do I need before the numbers mean anything?
There is no official threshold, but percentages on fewer than 20 or 30 deploys swing wildly with one event, as the nine-deploy example above shows. For low-frequency services, report counts ("1 of 9 deploys needed a fix") and look at trends over quarters rather than weeks.
What I check first on any DORA dashboard is the ratio of deployment objects to distinct successful SHAs, because it tells me in one query whether anyone has looked at the data underneath. In production I would run the two scripts from a nightly scheduled workflow, keep the SQLite file as an artifact, and only reach for DevLake once more than a couple of services need the same view. The official references are the DORA metrics guide, GitHub's Deployments and deployment statuses API docs, the commits and compare API and the DevLake DORA docs. If your CI is slow enough to dominate lead time, my guide on speeding up GitHub Actions is the next stop.
Related: How to Build a CI/CD Pipeline With GitHub Actions: Step-by-Step (2026), How to Set Up SLO Burn Rate Alerts in Prometheus: Sloth, Pyrra (2026) and How to Speed Up GitHub Actions CI: Caching, Sharding, Docker (2026).
I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.
Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.
Related
How to Build an AWS VPC With Terraform: Public and Private Subnets (2026)
Build a 3-AZ AWS VPC in Terraform with terraform-aws-modules/vpc 6.7.3 and AWS provider 6.70: public and private subnets, NAT per AZ vs single vs regional, a free S3 gateway endpoint, flow logs, outputs, an SSM connectivity test and fixes for common errors.
16 minOct 31, 2026How to Build a CI/CD Pipeline With GitHub Actions: Step-by-Step (2026)
Build a CI/CD pipeline with GitHub Actions from scratch: lint and test every pull request, Docker Buildx with the GHA cache, push to GHCR, staging and production environments with required reviewers, deploy by digest over SSH, concurrency, SHA-pinned actions and fixes for common errors.
18 minOct 31, 2026DORA Metrics Explained (2026): The Five Metrics, Benchmarks and AI
DORA metrics in 2026 are five, not four: deployment frequency, change lead time, failed deployment recovery time, change fail rate and rework rate. What each measures, the 2024 clusters and 2025 team profiles, how to measure honestly, how teams game them, and what AI did.
11 min