BugCI/CD

Governed Delivery Fails with delivery-preflight:pull-request-count When a Reused Branch Has Historical Merged PRs

A governed delivery pipeline that queries GitHub for all pull requests on a branch using --state all exits with delivery-preflight:pull-request-count whenever a previously merged PR shares the branch name with…

Rootlock SRE Engine 6 min read
Diagnostic brief

At a Glance

A governed delivery pipeline that queries GitHub for all pull requests on a branch using --state all exits with delivery-preflight:pull-request-count whenever a previously merged PR shares the branch name with the current open PR.

Severity Not rated
Confidence Medium
Frequency Unknown
Impact Degraded service

Summary

A governed delivery pipeline that queries GitHub for all pull requests on a branch using --state all exits with delivery-preflight:pull-request-count whenever a previously merged PR shares the branch name with the current open PR. The root cause is that the pipeline compares the total number of fetched PR snapshots—not the subset whose headRefOid matches the local HEAD SHA—against the required count of one. The practical impact is a complete delivery block: no intent is written and no provider action is emitted, even though exactly one PR points to the current exact head.

Root-Cause Analysis

Confirmed Evidence

  • Branch codex/939-full-auto-merge has two associated pull requests returned by GitHub when queried with --state all:
  • PR #1385: merged, headRefOid = ac36528f7cc526f81e34da1350f62e6e7f6a7c34 (historical head)
  • PR #1391: open, headRefOid = 8e738e8decb3baedd278b172592265800cfe2b54 (current local HEAD)
  • Running /task deliver #1390 exits with error code delivery-preflight:pull-request-count before any preflight check that inspects headRefOid.
  • The delivery pipeline treats the raw length of the fetched PR array (2) as the identity assertion. It requires that length to equal 1, so it fails immediately.

Root Cause

The identity check is applied too early and against the wrong cardinality. The pipeline performs the following steps in order:

  1. Fetch all PRs for the branch with --state all (required so that a post-merge delivery rerun can locate the merged PR and write its receipt).
  2. Assert fetchedArray.length === 1 — this is where the failure occurs.
  3. (Never reached) Filter or inspect headRefOid.

GitHub branch names are mutable and reusable across time. A branch that previously delivered PR #1385 and was kept open now also backs PR #1391. --state all correctly returns both. The pipeline, however, was written with the assumption that a branch maps to exactly one PR at all times, which is false for reused governed delivery branches.

The immutable delivery identity is the local HEAD commit SHA, not the branch name. Every other authority in the system—required checks, review gates, provider action authorization—already uses the exact HEAD SHA as the binding key.

Alternative Causes Ruled Out

  • Network or API pagination issue: Ruled out. GitHub deterministically returns both PRs; the count of 2 is correct, not a side effect of a partial response.
  • Incorrect --state flag: Ruling out merged state would fix the immediate symptom but break post-merge receipt recovery, which is a documented requirement. This is not the fix.
  • Duplicate open PRs at the same head: Not the case here. Only PR #1391 points to 8e738e8decb3baedd278b172592265800cfe2b54.

Resolution Steps

  1. Locate the PR snapshot array construction in the delivery pipeline, immediately after the GitHub branch PR fetch returns all results.
  1. Insert a headRefOid filter before any cardinality assertion:
   const currentHeadPrs = allBranchPrs.filter(
     (pr) => pr.headRefOid === localHeadSha
   );
  1. Replace the raw array length check with a check against currentHeadPrs.length:
  • currentHeadPrs.length === 0 → fail closed with delivery-preflight:pull-request-count (no PR for this exact head)
  • currentHeadPrs.length > 1 → fail closed with delivery-preflight:pull-request-count (ambiguous exact-head match)
  • currentHeadPrs.length === 1 → proceed; pass currentHeadPrs[0] to all downstream preflight, checks, and provider action logic
  1. Retain merged-PR recovery: After the filter, if currentHeadPrs[0].state === 'MERGED', continue the existing merged-receipt recovery path. The filter does not break this path because a merged PR at the exact current head is a valid recovery target.
  1. Preserve the delivery-preflight:head-mismatch error path: If the sole fetched PR exists but its headRefOid does not match localHeadSha (i.e., allBranchPrs.length === 1 and currentHeadPrs.length === 0), the error must remain delivery-preflight:head-mismatch, not delivery-preflight:pull-request-count. Ensure your filter logic does not collapse these two distinct failure modes.
  1. Add unit tests covering the three new cases via runDeliver:
  • One historical merged PR + one current-head open PR → selects the current PR, delivery proceeds.
  • Multiple PRs associated with the branch, zero matching local HEAD → fails with delivery-preflight:pull-request-count.
  • Multiple PRs associated with the branch, two matching local HEAD → fails with delivery-preflight:pull-request-count.

Configuration Snippets

No configuration file changes are required. The fix is entirely in delivery pipeline logic.

CLI Commands

Run the targeted delivery test suite after applying the fix:

node --test scripts/tests/unit/task-tracker/verbs/deliver.test.mjs

Run the full test suite:

npm test
npm run test:slow

Validate lint and formatting:

npm run lint
npm run format:check

Confirm the fix commit:

git log --oneline -1

Verification

Expected Behavior After Fix

  1. Delivery proceeds for PR #1391 when branch codex/939-full-auto-merge has both PR #1385 (merged, historical head) and PR #1391 (open, current head 8e738e8decb3baedd278b172592265800cfe2b54). The emitted provider action must name PR #1391 and reference its exact head SHA.
  1. Unit test suite exits 0:
   node --test scripts/tests/unit/task-tracker/verbs/deliver.test.mjs
   # Expected: all tests pass, exit code 0
  1. Full suite exits 0:
   npm test && npm run test:slow
   # Expected: exit code 0 across all lanes
  1. Zero exact-head matches still blocks delivery with delivery-preflight:pull-request-count; verify via the new unit test case.
  1. Two exact-head matches still block delivery with delivery-preflight:pull-request-count; verify via the new unit test case.
  1. Single wrong-head PR still fails with delivery-preflight:head-mismatch, not pull-request-count; verify existing test coverage remains green.

Rollback Indicator

If after the fix delivery-preflight:pull-request-count still fires when exactly one PR matches the local HEAD, the filter is either not applied or is comparing the wrong field. Confirm that headRefOid is populated in the PR snapshot object returned by the GitHub API client—some GraphQL projections omit it unless explicitly requested in the query fragment.

Prevention

Code Guardrails

  • Require headRefOid in the PR snapshot type/schema: Add a runtime assertion or TypeScript type guard that headRefOid is a non-empty string before the filter runs. A missing field silently produces zero matches, which would look identical to a legitimate no-PR state.
  • Encapsulate the filter as a named function (selectCurrentHeadPr or similar) so future callers cannot accidentally bypass it and access the raw branch PR array directly.

Testing

  • Maintain the three new exact-head test cases permanently as regression tests. They directly encode the contract: branch name is not delivery identity; exact HEAD SHA is.
  • Add a test for the merged-recovery path where the merged PR is at the current exact head, confirming that merged state does not disqualify a match.

Monitoring and Alerting

  • Alert on delivery-preflight:pull-request-count errors in CI/CD telemetry. Because this error is fail-closed, a spike indicates either a real ambiguity (two engineers targeting the same head) or a regression in the filter logic.
  • Log the full list of branch-associated PR numbers and their head SHAs at DEBUG level during delivery preflight. This makes future ambiguity diagnoses instantaneous without requiring a re-run.

Operational Procedures

  • Do not delete or force-push governed delivery branches between deliveries unless the branch PR history is explicitly reset. Keeping the branch intact is valid, but operators should expect --state all to return the full history.
  • Document the exact-head identity contract in the governed delivery specification: the local HEAD commit SHA is the sole binding delivery identity; branch name is a lookup key only.
Developer FirstBuilt for engineers solving real problems
Evidence DrivenTechnical claims tied to available evidence
Automation ReadyStructured for CLI, APIs, and workflows
Privacy FocusedNo unnecessary data collection in this article UI
STAY AHEAD OF ISSUES

Get new root-cause analyses in your inbox

Engineering-focused updates. No fake subscriber counts. Unsubscribe anytime.