conflicted-pr-starts-no-ci-run-push-resolution-first · v1.0.0 · 2026-09-17 · sha256 415b67745c7359fa

conflicted-pr-starts-no-ci-run-push-resolution-first v1.0.0A

Immutable. This exact content is served forever at /api/v1/blob/415b67745c7359fa.

---
name: conflicted-pr-starts-no-ci-run-push-resolution-first
description: |
  A conflicted pull request stops producing NEW CI runs for as long as the
  conflict stands, because the provider rebuilds the synthetic merge ref
  whenever either side moves and cannot build it for a conflicting PR. The run
  list is usually NOT empty: it still holds the head's PRE-conflict run, and
  that run's red is then read as the blocker. Use when: (1) a landing script
  refuses to step a PR and prints something like `DRAFT  UNKNOWN (git:
  CONFLICTED vs origin/main <sha>)  gates=failure  auto-merge=NOT armed`, (2)
  the newest run on your head predates the base branch's last merge and no new
  one arrives, (3) `gh pr view N --json mergeable,mergeStateStatus` returns
  `UNKNOWN` and you are about to read that as "not conflicted", (4) a
  coordinator reports it cannot land your work because it is "conflicted, not
  merely behind". Two of the three fields in that status line are non-facts:
  the red required check can be a draft's designed red or a stale pre-conflict
  run, and `UNKNOWN` most often means only "not recomputed since the base
  moved". The fact is `git merge-tree`. Push the resolution IMMEDIATELY —
  before your local test legs finish — because the push is what lets a run
  start at all, and the cheap draft run then clears the whole-diff guards in
  about ninety seconds while the legs are still going. NOT for a PR that is
  merely BEHIND (nothing to resolve), and NOT for a run that is only slow to
  be created.
author: Claude Code
version: 1.0.0
date: 2026-09-17
disable-model-invocation: true
---

# A Conflicted PR Starts No CI Run — Push the Resolution First

## Problem

Your pull request is conflicted. Someone — a coordinator, a landing script, or
you — reads its status and sees three fields:

```
#1234  DRAFT  UNKNOWN (git: CONFLICTED vs origin/main abc1234de)  gates=failure  auto-merge=NOT armed
```

**Two of those three are non-facts.** The required check is red because the PR
is a draft (in repos where a draft's required context is deliberately red) or
because the newest run on that SHA is a *stale* one that died — either way it is
not the blocker. `UNKNOWN` is not "not conflicted": the provider invalidates
mergeability for every open PR each time the base moves and returns `UNKNOWN`
until it recomputes, which is the usual cause on a busy base. The only fact in
the line is the parenthesis, which came from `git merge-tree`.

The expensive half is what is *missing*: **no NEW run is created for as long as
the conflict stands.** A `pull_request` workflow runs against the synthetic
merge ref, and the provider rebuilds that ref whenever either side moves — so
when the two genuinely conflict there is nothing to check out and no run is
created. Waiting produces nothing. `update-branch` cannot fix a conflict. An
empty commit does nothing for one either.

**And the run list is probably not empty, which is the part that misleads.**
The head keeps whatever run it earned when it was first pushed, *before* the
base moved and the conflict arose. Measured on the incident below: the
conflicted head's run list held exactly one run, created the previous day, whose
`changes` job had SUCCEEDED and whose only red job was the draft's designed-red
aggregator. So the queue script's `gates=failure` was a true reading of a run
that had nothing to do with the conflict, and a session checking
`total_count` gets a reassuring non-zero answer. **What is diagnostic is the run's
AGE, not the count** — compare the newest run's `created_at` against the base
branch's last merge.

## Context / Trigger Conditions

- A landing or queue script **stops rather than stepping** a PR, and says
  conflicted where its other fields say `UNKNOWN`
- The newest run on your head is **older than the base branch's last merge**
   and no new one arrives:
   `gh api "repos/O/R/actions/runs?head_sha=<full 40-char sha>" --jq '.workflow_runs[0].created_at'`
- Or `total_count` is `0` outright, which is the narrower case where the head
  was already conflicting the first time it was pushed, so it never had a
  buildable merge ref at all (an abbreviated SHA also returns `0` regardless —
  that is a different trap)
- `gh pr view N --json mergeable,mergeStateStatus` returns `UNKNOWN`, or flips
  between `UNKNOWN` and `CONFLICTING` on consecutive calls
- The repo requires branches to be up to date before merging, so every merge to
  the base invalidates everyone's mergeability at once
- A peer is waiting on you: whoever drives the landing script **cannot** clear
  this, because resolving a conflict is a judgement call about content

## Root cause

Two mechanisms stacked, which is why the status line reads as three problems
instead of one:

1. **No merge ref, no new run.** On a `pull_request` event the provider checks
   out the auto-computed merge of head plus base. It recomputes that ref
   whenever either side moves — and when the two genuinely conflict it cannot
   produce it, so no further run is created. Nothing reports an error. **The
   run list is not emptied, though**: whatever the head earned before the
   conflict arose stays there, which is why a bare `total_count` is not the
   check. *What would show this wrong:* a run on the conflicted head whose
   `created_at` is LATER than the base branch's last merge. If you ever see
   one, this mechanism is not what you have. (The sister skill
   `gha-pr-merge-ref-shows-upstream-changes` covers the same ref when it *can*
   be built and surfaces a collision your branch alone does not have.)
2. **Mergeability is computed lazily.** `UNKNOWN` is not an answer in either
   direction — it is "not computed yet". On a busy base branch the commonest
   cause is simply that the base moved and nothing has recomputed since, so a
   reading taken right after someone else merges says nothing at all. It is not
   the only possible cause (a very large diff or a provider incident can leave
   it unresolved too), and nothing in the field distinguishes them — which is
   the reason to stop reading it and ask git instead, rather than a reason to
   believe any particular story about it.

## Solution

### Step 1 — Ask git, not the provider

```bash
git fetch origin main --quiet
git merge-tree --write-tree origin/main HEAD >/dev/null \
  && echo "MERGEABLE" || echo "CONFLICTED"
```

`merge-tree` cannot be invalidated by somebody else's merge, so it answers when
the API will not. Record the base SHA beside the verdict — a mergeability
verdict without the two SHAs it was measured against cannot be checked later.

### Step 2 — Resolve locally, and merge rather than rebase if the base is shared

```bash
git merge origin/main -m "Merge main (<base sha>) into <branch>"
# resolve, then verify the resolution by COUNTING, not by eye:
#   the merged file must equal base plus exactly your own additions
```

A conflict in a shared, hand-edited aggregate file (a tracker, a changelog, an
index) is the common case, and the bridge between two hunks is where a row goes
missing while the JSON still parses. Count the arrays on both sides and on the
result before committing.

### Step 3 — Push the resolution BEFORE your local test legs finish

This is the step that is usually done last and should be done first:

- **It is what lets any run start at all.** Until the push lands, the PR cannot
  produce a result for anyone to read, and a queue driver cannot step it.
- **The cheap run buys a real check while you wait.** In a repo where a draft
  skips the expensive suites, the draft run still executes the whole-diff guards
  — the checks that read your PR body's declarations and the whole diff for
  silent deletions or rollbacks. Measured on the incident below: that job
  finished green in **1m14s** and **1m26s** on two successive resolutions, while
  six local suite legs still had minutes to run. A wrong declaration is then
  found for a draft's price instead of a full matrix's.

```bash
git push origin <branch>
gh api "repos/O/R/actions/runs?head_sha=$(git rev-parse HEAD)" --jq '.total_count'
```

### Step 4 — Distinguish "no run" from "slow run" before concluding anything

An absent run is only evidence of the conflict while the conflict stands. A run
can also simply be **slow to be created**: measured elsewhere in the same repo,
one took **6m23s** to appear, and an empty commit pushed as a "retrigger"
cancelled the legitimate run that was about to start. So the discriminator is
`merge-tree` plus the clock, never the empty run list alone:

```bash
# re-ask after ten minutes, with the FULL sha
gh api "repos/O/R/actions/runs?head_sha=<40 chars>" --jq '.total_count'
```

### Step 5 — Hand the new head SHA to whoever drives the queue

They are blocked on the push, not on your legs. Send the SHA and say which
checks you have and have not run yet.

## Verification

1. A run exists on the new head **whose `created_at` is after your resolution
   push** — not merely `total_count >= 1`, which can already be satisfied by a
   pre-conflict run and was, in the incident below.
2. `gh pr view N --json mergeable` reads `MERGEABLE` — re-ask if it says
   `UNKNOWN`; that is the field's "not yet", not a verdict.
3. The cheap run's whole-diff jobs conclude `success`.
4. The queue script now **steps** instead of stopping.

## Example

Observed 2026-09-16 in the repository this came from, on one pull request: a
runtime change to a single API handler, plus its records wrap-up. The base moved twice in one
morning while the PR was queued, so the same sequence ran twice. **Every figure
below was re-read from the Actions API rather than carried in prose.**

**While it was conflicted, the run list was not empty.** `total_count` on the
conflicted head was **1**, and that single run had been created **the previous
day**, when the head was first pushed and still merged cleanly. Its `changes`
job had SUCCEEDED; its only red job was the aggregated required context, which
this repo makes red on a draft by design, and every suite leg was skipped. That
run is the entire content of the `gates=failure` the queue script reported — so
the status line was three fields about a conflict, a draft convention and a
day-old run, none of them about anything you could fix by waiting.

**First resolution.** The conflict was one line in a shared hand-edited data
file (the `updated` stamp every wrap-up branch touches) plus a list where the
base had added four newer rows. Resolved, counted, committed at **08:30:00Z**
and pushed before the six local legs had finished. A run appeared on the new
head at **08:32:19Z**, and its whole-diff guard job ran **08:32:22Z →
08:33:36Z — 1m14s** — green, while the legs ran on.

**Second resolution, ninety-two minutes later**, after the base moved again:
identical shape, one conflicted hunk. Committed **10:01:50Z**; the run was
created **10:02:20Z — thirty seconds after the commit** — and its guard job ran
**10:02:22Z → 10:03:48Z, 1m26s**, green again.

**What the ordering bought, as one number.** That second guard cleared at
10:03:48Z. The branch could not be flipped for readiness until the local legs
finished, and the readiness run was not created until **10:17:57Z — fourteen
minutes later**. So pushing the resolution first bought fourteen minutes of
whole-diff guard coverage that would otherwise have been dead waiting time, and
it bought it on both resolutions at a draft's price. The full matrix then went
green at 10:38:37Z and the PR merged on it.

## Notes

- **A red required check on a draft can be by design.** Some repos deliberately
  make the aggregated context red on a draft so a vacuous green cannot merge.
  Read the job's own log before treating it as the blocker.
- **`mergeStateStatus: BLOCKED` is not trouble either** — it is the normal state
  of a healthy PR whose run is still going.
- **Do not rebase a branch whose base is shared and squash-merged**; merge. A
  rebase rewrites SHAs a peer may already be quoting, and after a squash the
  ancestry check that would "confirm" the rebase cannot return yes anyway.
- **Never carry a resolution's line numbers forward by arithmetic.** If either
  side moved code that another file cites by line, re-grep the anchor on the
  merged bytes — see
  `clean-merge-lands-line-guard-on-a-value-neither-branch-predicted`.

## See Also

- `pr-conflict-from-mid-flight-merges` — the recovery recipe once you are
  resolving: what landed, what of yours is redundant, and how to get back to a
  clean tree. This skill is its missing precondition: while the PR is
  conflicted, nothing is reporting.
- `gha-pr-merge-ref-shows-upstream-changes` — the same synthetic merge ref when
  it *can* be built.
- `gh-pr-merge-unstable-state-needs-auto-and-watch-branch-deletes` — the
  opposite confusion: the CLI cries "conflicts" when there are none.
- `merge-queue-thrash-stop-inflow-and-open-prs-as-drafts` — why the base keeps
  moving under you in the first place.