Skip to main content
日本語

Trust the Exact SHA, Not the Pull Request Number

Tomohiro Iida · Published August 25, 2026 · Updated August 25, 2026

When AI agents implement code, their reports arrive in natural language: CI is green, the change is reviewed, it is on main. On the GitHub side, however, there are seven states that can each be a different commit: the pull request head, the commit CI actually tested, the commit a review read, the commit an owner authorized, the commit created on main by the merge, the commit a deployment built, and the commit production is currently serving. What looks like one thing in a conversation is seven things in the system.

Key takeaways

  • In AI agent operations, the identifier for the object being operated on is the 40-character commit SHA, not the pull request number.
  • Give separate names to seven SHAs: PR_HEAD_SHA, CI_TESTED_SHA, REVIEWED_SHA, OWNER_AUTHORIZED_SHA, MERGED_SHA, DEPLOYED_SHA, and PRODUCTION_SHA.
  • Fetch the pull request head from two independent paths and compare them. Treat UNKNOWN as DENY, never as ALLOW.
  • A green CI run only proves that some commit passed. It does not prove which commit, nor that the head has not moved since.
  • The commit produced by a squash merge is not the pull request head, so exact-head CI from the pull request cannot be reused for it.

Incident card

FieldValue
IncidentThe remote branch carried newer commits, but the GitHub pull request API kept returning an older head, and the Controller merged that older head.
SymptomA state that dropped three verified fixes, including two known high-risk findings, reached main.
False assumptionIf the pull request number and the green CI badge line up, the merge target is the same code that was verified.
Root causeNo step existed that fetched the verified SHA and the operated SHA separately and compared them, so a single displayed head was trusted.
Immediate fixRe-fetch the remote branch ref through an independent path immediately before merging and refuse to merge on mismatch.
System fixGive seven SHAs distinct names and deny whenever the identity equation breaks or a value cannot be read.
Remaining riskA squash merge commit differs from the pull request head, so the exact-head CI evidence cannot be carried over to it.

A pull request number is a container whose contents change

A pull request number identifies a unit of work. The commits inside it change through pushes, rebases, force pushes, and base branch advances. The number does not change. That is why none of the following sentences identifies a verification subject on its own: PR #1234 is green, PR #1234 is reviewed, PR #1234 was merged. Each one omits which version of #1234 is meant.

On 2026-08-17 this happened in the Netsujo repository. Three commits were already pushed to the remote branch, but the pull request API kept returning an older head. The Controller merged that head, and a state that dropped three verified fixes reached main. The merge API sha precondition did not help: it compares the head that GitHub itself holds, not the actual remote ref, so a stale head simply matches another stale value.

The false assumption: green CI means the merge target is verified

A green badge shows only that some commit passed some checks. It does not show which commit was tested, whether that commit is still the head, whether the job was a required check, whether checks were skipped because of the change set, or whether the head advanced after the run finished.

The same gap appears on reruns. A manual rerun in GitHub Actions replays the original event payload, so a green rerun reports on the SHA from the original event, not the SHA at rerun time. The repository closes this with a first step in ci.yml called Reject stale PR head rerun, which compares the event head SHA against the live branch ref over the Git protocol and fails with STALE_PR_HEAD when they differ. A separate workflow, pr-exact-head-ci.yml, cross-checks the local checkout, the event head, and the remote branch ref, and fails if any of the three is missing or disagrees.

Root cause: identity checking was not implemented anywhere

The cause was not the judgement of the AI or the wording of its report. No step on the path mechanically compared the SHA that was verified with the SHA that was operated on. Human teams hit the same structure, but running several agents in parallel raises both how often main moves and how many reports arrive, and visual checking stops keeping up.

Immediate fix: re-fetch the head through an independent path

The first fix was simple. Just before merging, fetch the remote branch ref from a source other than the pull request API and compare. The source of truth for this decision is evaluateHeadIdentity in scripts/agent-os/integration-controller.mjs, which requires the following equation to hold.

PR_API_HEAD_SHA = REMOTE_BRANCH_REF_SHA = REQUIRED_CI_TARGET_SHA = MERGE_EXPECTED_HEAD_SHA

StateCodeDecision
Pull request head unavailablePR_HEAD_UNKNOWNDENY
Remote ref unavailableREMOTE_REF_UNKNOWNDENY
Pull request head differs from remote refSTALE_PR_HEADDENY
CI target SHA differsCI_TARGET_MISMATCHDENY
Expected merge head differsMERGE_HEAD_MISMATCHDENY

The important part is that a value which could not be fetched also results in DENY. Turning UNKNOWN into ALLOW converts a missing check into an automatic approval.

System fix: give seven SHAs seven names

NameWhat it points atWhere it comes from
PR_HEAD_SHAThe commit the pull request head points atBoth the pull request API and the remote branch ref
CI_TESTED_SHAThe commit CI actually checked out and testedThe ref a workflow run targeted
REVIEWED_SHAThe commit whose diff a review readThe head at review submission time
OWNER_AUTHORIZED_SHAThe commit an owner authorized for mergeThe owner marker in the pull request body
MERGED_SHAThe commit created on main by the mergeThe resulting merge commit
DEPLOYED_SHAThe commit a deployment builtThe execution subject of the deploy workflow
PRODUCTION_SHAThe commit production is currently servingMetadata of the deployment behind the alias

Separate the right to verify from the right to record

In candidate-exact-sha-gate.yml the job that executes candidate code has no statuses write permission, and the job that writes the status executes none of the candidate code. Verification code and its dependencies can run arbitrary commands, so placing a write token there would let the verdict itself be forged. Identity design is not finished when values are compared; it also has to answer who is allowed to write those values.

A reusable exact-SHA checklist

Avoid abbreviated SHAs. Every decision runs on 40 lowercase hexadecimal characters, and a value that fails the format check fails before anything else happens.

Evidence

StatementClassSource
An older pull request head was merged and three verified fixes were dropped on 2026-08-17.OBSERVEDRecorded as incident context in scripts/agent-os/integration-controller.mjs
Merge identity is decided by a four-value equation and UNKNOWN results in DENY.IMPLEMENTEDscripts/agent-os/integration-controller.mjs
Authorization is validated as a pair of pull request number and 40-character SHA.IMPLEMENTEDownerMergeAuthorization in scripts/agent-os/integration-controller.mjs
Reruns compare the event head SHA against the live branch ref and fail on mismatch.IMPLEMENTEDReject stale PR head rerun in .github/workflows/ci.yml
The pull request head is confirmed from local checkout, event payload, and remote ref.IMPLEMENTED.github/workflows/pr-exact-head-ci.yml
The post-merge candidate SHA is checked out again for a full gate and a recorded status.IMPLEMENTED.github/workflows/candidate-exact-sha-gate.yml
The job executing candidate code holds no status write permission.IMPLEMENTED.github/workflows/candidate-exact-sha-gate.yml
The commit production serves is resolved from the alias and mismatches fail closed.IMPLEMENTEDscripts/deploy-policy/resolve-production-sha.mjs
Machine-enforced SHA identity makes mistaken targets surface earlier.INFERREDThe size of the reduction is not measured.

Limitations

Remaining risk

Frequently asked questions

Is using pull request numbers itself the problem?
No. A pull request number is a convenient human reference. What to avoid is deciding whether something is verified based on the number alone. Converse in pull request numbers and decide on 40-character SHAs.
If CI is green, can the change be merged as is?
Green is a result for one commit. Only after confirming that the commit is still the head, that the job was a required check, and that no check was skipped for the change set can the merge target be called verified.
Does CI need to run again after a squash merge?
The pull request head and the post-merge SHA are different objects, so the earlier result cannot be reused. Netsujo checks out the post-merge candidate SHA, reruns lint, typecheck, tests, and the build, and records a status on that SHA.
Can abbreviated SHAs be used in operations?
Not recommended. Our operating rule fixes every decision value at 40 lowercase characters and fails as soon as the format differs. Abbreviations are limited to human-readable display.
If an AI reports that the SHAs match, can that be trusted?
Self-reporting is not evidence. Check the path the value was fetched from, the run it belongs to, and the decision code in the record. The right to decide and the right to write the result also need to be held by different jobs.

Continue in this series

Previous: Stopping the chat does not stop the work

Next: CLOSED is not MERGED

Back to the twelve-part AI agent incident record

If pull requests, CI, approvals, merges, deployments, and production have drifted into separate states after adopting AI agents, we help design the identity checks and evidence that tie them back together.

Discuss AI implementation and development operations design