← The ADLC library
Field guides · 23

Finding the work GitHub never recorded

GitHub is a better witness than most teams give it credit for. The gaps are narrow and specific: work that bypassed the pull request, work in repositories nobody inventoried, and evidence that expired. Here is how to find all three.

You are trying to account for everything that changed in production over some period, and you have a nagging sense that the pull request list is not the whole story. It usually is not. But the gaps are narrower and far more specific than the vague unease suggests, and you can enumerate them in an afternoon.

Start by giving GitHub credit

GitHub records more than people assume, and if you go in expecting it to be full of holes you will build tooling you did not need.

Every commit that reaches a branch on the remote is durably recorded with an author, a committer and a timestamp. Every pull request keeps its description, its review history, its approvals, its status checks and its merge event. Branch protection and repository rulesets can require a pull request before anything lands on your default branch, and can require the checks that gate it. Organisations get an audit log covering administrative events, subject to a retention window you should check for your own plan rather than assume.

For a team that merges through pull requests into protected branches, that is a genuinely good record. The question is not “does GitHub record things”, it is “what got past it”.

The three escape routes

Bypassed the pull request

Direct pushes to a protected branch, or merges by someone on the protection bypass list. Recorded as commits, but with none of the review context.

Outside the inventory

Repositories nobody counts: personal-account repos, forks in individual namespaces, archived repos still being deployed from, and repos created after your inventory was written.

Expired or rewritten

Workflow logs and artifacts past their retention, deleted branches, force-pushed history. GitHub recorded it and then, correctly, stopped keeping it.

Only the second route is invisible to GitHub itself. The other two are visible if you ask the right question, which is why the sweep below is mostly a query exercise.

There is a fourth thing people call an orphan that is really something else: a merged pull request with no linked issue or work item. That is a tracker linking problem, not a GitHub recording problem, and it is worth keeping separate because the fix is different.

The sweep

Work from a repository inventory outward. Everything else depends on having the right denominator of repositories, and this is the step most audits get wrong.

Enumerate repositories from the organisation, not from memory. List every repository the organisation owns, including archived ones, and record for each: default branch, whether protection or a ruleset applies to it, when it was last pushed to, and whether anything deploys from it. The command line client will do this and paginate for you. Then, separately, ask your deployment tooling which repositories it deploys, and diff the two lists. Anything deployed from a repository not in the organisation inventory is your highest-value finding of the day.

Pull merged pull requests over the window.

gh pr list --repo ORG/REPO --state merged \
  --limit 500 \
  --json number,title,author,body,mergedAt,baseRefName,headRefName
Field names vary between versions of the client, so confirm which ones yours supports before hard-coding a list.
Note the explicit limit. The default page size is small, and an audit that silently truncates at the default is the classic way to under-report.

Find commits on the default branch that no pull request explains. Walk the first-parent history of your default branch over the window with git log --first-parent, then, for each commit, ask GitHub which pull requests it is associated with. That association is exposed through the API, though the exact route differs between the REST and GraphQL interfaces, so check the current documentation for whichever you use. Commits with no associated pull request are your bypass population.

Read the bypass configuration, not just the bypass events. Branch protection and rulesets both allow specified actors to skip the rules. Print that list for every protected branch and put it in front of a human. In most organisations it has accumulated: a deploy bot from a migration three years ago, two people who have changed teams, and an admin group that has quietly grown. A rule with a wide bypass list is a rule that reports compliance and enforces nothing.

Check retention before you rely on evidence. Workflow run logs and artifacts have a finite retention period that is configurable at organisation and repository level. If your plan is to reconstruct what a build did nine months ago from its logs, verify that the logs still exist before you promise it to anyone. If they matter, export them somewhere with a retention policy you control.

Closing the gaps, cheapest first

Fix the inventory problem first. It is the only gap that is genuinely invisible, and it is usually the cheapest to close. Require that anything deployed to production lives in an organisation-owned repository, and run the deploy-target diff on a schedule so drift shows up in a week rather than at the next audit.

Trim the bypass lists. No new tooling, no new process, and it converts an ineffective control into an effective one. Do it repository by repository, with a named owner for each remaining exception and a date to review it.

Make the emergency path recorded rather than forbidden. Teams push directly to main at 2am because the correct thing to do for the customer is to fix it now. Forbidding that outright pushes the work somewhere less visible. A better arrangement is a documented break-glass route that is allowed, logged, and generates a follow-up automatically, so the record arrives an hour after the fix instead of never.

Automate the sweep before you buy anything. The repository inventory diff, the merged pull request pull and the unassociated-commit query are a few hundred lines against APIs you already have credentials for, scheduled weekly, writing to a table. For most organisations that is the whole answer, and it is worth exhausting before looking at products.

If you do one thing from this guide, print the bypass list for every protected branch in your organisation. It takes a morning, needs no new tooling, and in most organisations it is a genuinely surprising document.

Where this breaks down

Not all production change comes from a repository. Feature flags flipped in a vendor console, configuration edited in a cloud provider’s web interface, a database migration run by hand, a model or prompt changed in a hosted product’s settings: none of it appears in GitHub, so none of it appears in any of the above. If a meaningful share of your production change happens outside version control, this sweep will find your repository gaps and quietly reassure you about a category it cannot see at all.

Commit authorship is not identity. The author field on a commit is set by the client and is not proof of who wrote anything. Signed commits raise the bar, unsigned ones do not. Treat authorship as helpful metadata for a conversation, never as evidence for an accusation, and be especially careful now that a growing share of commits are produced by agents running under a human’s credentials.

Force pushes can rewrite what you are auditing. On a branch that allows them, history you enumerated last month may not be the history you find this month. The remote’s reflog and the audit log may retain traces, subject to retention. If immutability matters to you, protection settings that forbid force pushes on the branches you care about are the actual control, not a later reconstruction.

A complete inventory of changes is not an inventory of risk. You can finish this exercise with a defensible list of everything that shipped and still have no idea which of those changes could hurt you. That is a different problem, it is solved by classifying blast radius rather than by counting merges, and it is worth being clear about the difference before someone treats a coverage number as a safety number.