Skip to content

fix(pr-pipeline): delete a stopped lane run's branch when the issue re-runs - #11224

Merged
kyle-sexton merged 8 commits into
mainfrom
fix/lane-stopped-branch-reuse
Oct 30, 2026
Merged

kyle-sexton merged 8 commits into
mainfrom
fix/lane-stopped-branch-reuse

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

Closes #7948

Summary

A work-resolve-issue run that stops after pushing leaves its lane branch behind, and a re-run on the same issue makes a new branch without touching the old one. Per the owner decision on #7948 (delete a stopped run's branch at the re-run when it has no pull request; keep it when one exists) and the recorded answers to the refinement's four questions, the next run on the issue now deletes those branches.

Departures from the spec:

  • Deletion uses the GraphQL updateRefs mutation pinned to beforeOid (the tip the job read) instead of the spec's REST DELETE. It is still one request per branch, and a branch pushed to after the read is not deleted.
  • The prune job runs after the resolve-issue calls, which run after issue-prior, so it still meets "after issue-prior".

Fix

  • report-outcome.mjs: new pure pruneBranches. With the model running or a branch reused, --find-prior names, as the prune-branches step output, every lane branch of the issue that meets all of these: its run posted a lanes-App outcome marker (a stopped run), every commit ahead of the default branch is the lanes App's, and it has no pull request in any state. It spares the branch the run reuses, resumes or starts fresh past, any unreported branch, and every attempt of the run itself (GITHUB_RUN_ID). It reports at most 20 per run and decides from the reads --find-prior already makes.
  • pr-run-activity-read.yml: prune-branches goes from the run job through the issue-fields job. There it must be at most 20 distinct lane branches of the issue, none of them the reuse or resume branch. Because the run job runs the model, issue-fields re-derives --find-prior whenever a prune list is claimed, and publishes only the names both lists hold, as scan-prune-branches. A failed re-derive publishes none. This came from the independent security review.
    • The publish step reads a missing re-derive file as empty. A plain fresh run (no claim, no prune list) skips the re-derive, and before this fix prior() returned 1 there, so bash -e ended the step before checked=true and every such run would stop.
    • The re-derive runs whenever the base checkout runs: checkout on a claim or a non-empty prune list, Node on a successful checkout, the re-derive on a successful Node setup.
  • work-resolve-issue.yml: new prune-lane-branches job, after issue-prior (it needs the resolve-issue calls). It holds the lane's only contents: write grant, with pull-requests: read, on its own GITHUB_TOKEN, and runs prune-branches.mjs from the default-branch tip. Because the list came from the model's runner, the job treats it as a claim:
    • It drops any name that is not this issue's lane branch, belongs to this run, or is the reuse or resume branch.
    • It reads every remaining branch in one GraphQL request (associatedPullRequests(states: [OPEN, CLOSED, MERGED])).
    • It also keeps a branch an open pull request targets.
    • It deletes only a branch with a PR count of 0, one updateRefs per branch whose beforeOid is the tip it read (the delete mutation the daily branch cleanup already uses).
    • A failed read deletes nothing, and a refused delete is logged. Neither fails the run.
  • The stale-branch comment from feat(pr-pipeline): give a fresh lane restart the earlier branch's partial work #11025 used to promise "no lane deletes it". It now says the next run on the issue deletes the branch. The run that starts fresh past a stale branch still spares that branch, because it builds on its commits. A later run deletes it.
  • The daily branch-cleanup rule is unchanged (refinement question 4). Docs: pr-run-activity.md and the branch-cleanup section of the README.md.

Verification

  • Red, then green, for every new or changed test:
    • report-outcome.test.mjs failed to load without pruneBranches. It now passes 326 of 326.
    • prune-branches.test.mjs could not find its module. It now passes 8 of 8.
    • callers.test.mjs run against main's workflows failed 5 tests (issue-fields outputs, the prune job, the output list). It now passes 207 of 207.
    • The publish step under bash -e with no re-derive file and CLAIMED=false: on d22a582 it exits 1 with no outputs; after the fix it exits 0 with checked=true and an empty prune-branches, for an empty and a non-empty prune list. A new assertion also checks that the Node setup and the re-derive each run exactly when the step before them succeeded.
  • The acceptance criteria are each covered by a test:
    • A stopped run's branch with no PR is named and deleted.
    • A branch backing a pull request in any state is kept, both in pruneBranches and in the prune job's read.
    • The run's own branches, and the reuse and resume branches, are never deleted, even when a forged list names them. prune-branches.test.mjs and pruneBranches cover this.
    • The delete runs in a work-resolve-issue.yml job with contents: write, one request per branch, and the read job's token is not widened. callers.test.mjs asserts the job's permissions, and that no other job of the lane holds contents: write.
  • write-issue, check-blockers, branch-cleanup, check-read-caller-keys and the other workflow-reading suites pass. actionlint and zizmor are clean on both workflows.

Metrics

Per-run GitHub API call delta:

  • --find-prior (run job and issue-fields): +0 reads. The list comes from the matching-refs, compare, pulls and commit reads it already makes.
  • issue-fields: +0 on a run with nothing to prune. On a run that names k branches, it re-runs --find-prior once, which costs the existing reads again: about 4 plus 3 per lane branch of the issue. These are the same reads as the run job's, not a new per-item loop. Since the prune job then deletes those branches, the cost does not recur.
  • prune-lane-branches: skipped with 0 calls when nothing is named. Otherwise it makes 1 GraphQL read plus k updateRefs writes, where k ≤ 20 is the number of leftover branches it deletes. Each leftover branch is deleted once, so the writes do not recur. There is no per-item read loop.

What this makes measurable: each delete logs its branch and tip in the job log. The issue's "Checks a person runs" item, the count of work-resolve-issue/* branches with no pull request, should stop growing once this ships.

Related

🤖 Generated with Claude Code

kyle-sexton and others added 4 commits October 30, 2026 06:21
…e-runs

When a work-resolve-issue run starts on an issue, issue-prior now names
the issue's lane branches left by earlier stopped runs: a branch whose
run posted a lanes-App outcome marker, whose commits are all the lanes
App's, and that has no pull request in any state. It spares the branch
the run reuses, resumes or starts fresh past and every branch of the
run itself, and decides from the reads it already makes.

A new prune-lane-branches job in work-resolve-issue.yml, the lane's
only contents: write, takes that list through issue-fields' shape
check, drops the run's own, reuse and resume branches, re-reads every
named branch in one GraphQL request, and deletes only one with no pull
request, one updateRefs per branch at the tip it read.

The stale-branch comment now says the next run deletes the branch.

Closes #7948

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kyle-sexton
kyle-sexton marked this pull request as ready for review October 30, 2026 10:28
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@github-actions

github-actions Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

claude-security-review reviewed ba73fef22e981f7fd5f8569a6e374070af90c884: 0 finding(s), 0 as inline comments.

No security findings in the incremental change since d22a582.

critical-findings: 0

I read the two files in .github/actions/resolve-config/callers.test.mjs and .github/workflows/pr-run-activity-read.yml. Both are on the trusted-path list, so I read the whole change and what it gates.

  • .github/workflows/pr-run-activity-read.yml:2170: prior() changed from [ -f F ] && sed … to [ ! -f F ] || sed …. A missing re-derive file now makes prior return 0 with empty output, where before it returned 1.
    • Why it matters: on a plain fresh run (CLAIMED=false) the re-derive steps are skipped, so the file is never written. Before the change, derived=" $(prior prune-branches) " took the status of the failed substitution. Under the default bash -e, that status made the step exit before it published anything.
    • After the change: derived is empty, so prune stays empty and nothing is pruned. A forged PRUNE_BRANCHES list still publishes nothing the re-derive didn't name.
    • Claimed runs: the step's if: still requires steps.prior.outcome == 'success', so the file exists on those runs. The field-for-field comparison at lines 2172–2173 is unchanged. If the file were somehow missing, the empty values would only match claims that are themselves empty, so there is no widening.
    • Trust controls: the re-derive's trusted-actors input (.base/…/trusted-actors.json, from the default-branch checkout) and the token scope are untouched.
  • .github/actions/resolve-config/callers.test.mjs: the change only adds test assertions. They cover the fresh-run case with no re-derive file, and the if: conditions on the node and prior steps (each runs only after the previous step succeeded). It adds no code that runs in a trusted context.

The change widens no trust control and does not touch the kill switch, token scope, or merge holds. Nothing here spends anything beyond existing prepaid or subscription usage.

@github-actions

github-actions Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

claude-review reviewed ba73fef22e981f7fd5f8569a6e374070af90c884: 0 finding(s), 0 as inline comments.

Incremental review of the changes since d22a582: .github/actions/resolve-config/callers.test.mjs and .github/workflows/pr-run-activity-read.yml. No findings.

  • prior() fix (pr-run-activity-read.yml:2170): the old [ -f "$PRIOR_OUT" ] && sed … returned 1 when the file was missing. At line 2179, derived=" $(prior prune-branches) " is a bare assignment, so under bash -e that status ended the step before checked=true was written. The new [ ! -f … ] || sed … | tail -n 1 returns 0 and prints nothing when the file is missing. With the file present, the status is the pipeline's, as before. The $(prior …) calls inside [ … ] at lines 2172–2173 were never affected.
  • Trust-boundary effect: a missing re-derive file still yields an empty derived, so nothing is published as a prune name. The claim check (lines 2171–2177) is unchanged: a claim with a failed re-derive is blocked by the step's if at line 2159. The fix does not widen what gets published or deleted.
  • New tests: the no-file loop uses runStep, which runs bash -eo pipefail like the real step. It covers an empty and a non-empty prune list and expects checked=true with an empty prune-branches. The new if assertions check that node runs only after a successful base and prior only after a successful node, which matches the workflow at lines 2126 and 2138. prior, node and base are all in scope in the test.

I did not run the tests. The checks above come from reading the diff and the surrounding workflow and test code.

Nothing here changes spend, so there is no cost hold.

kyle-sexton and others added 2 commits October 30, 2026 06:33
The run job that names the branches to prune runs the model, so a forged
list could name a reuse or resume candidate, the stale branch, or a
branch a person pushed to. issue-fields now re-derives the earlier-work
check whenever a prune list is claimed and publishes only the names both
lists hold. The prune job also keeps a branch an open pull request
targets, and its log names a restore that works from GitHub's copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

claude-review: 1 inline finding(s) on d22a582dd4034924e222d355e3c277c16cd091a4; the summary is in this lane's pull request comment.

claude-lane: claude-review

@github-actions

Copy link
Copy Markdown
Contributor

The pr-verify verifier failed head d22a582dd4034924e222d355e3c277c16cd091a4. What it wrote, as data:

What it wrote (32 lines)
Summary: Issue #7948: the PR covers all four acceptance criteria. Stopped-run lane branches with no
  PR in any state are named, held to the issue's lane-branch shape, re-derived and then deleted. The
  run's own, reuse and resume branches are spared, with tests. The delete runs in a new `contents:
  write` job in work-resolve-issue.yml, one request per branch. One blocker: the rewritten "Publish
  the checked issue fields" step now calls `prior()` on every run. That function returns 1 when
  `$PRIOR_OUT` does not exist. So under the default `bash -e` shell, a fresh run with no claim and
  nothing to prune would exit before writing `checked=true`, and every plain fresh issue run would
  stop. The tests always create `$PRIOR_OUT`, so they cannot catch this.
- Blocker. .github/workflows/pr-run-activity-read.yml:2170 (Publish the checked issue fields):
  `prior()` moved out of the `if [ "$CLAIMED" = true ]` block and is now `[ -f "$PRIOR_OUT" ] && sed
  ... | tail -n 1`, which returns 1 when the file is missing. `derived=" $(prior prune-branches) "`
  now runs on every run. On a fresh run with no claim and an empty prune list, the base checkout and
  the re-derive both skip, so `$PRIOR_OUT` is never written. Under GitHub's default `bash -e {0}`,
  the failing assignment ends the step before `checked=true` is written, and the scan job then
  publishes nothing. Fix: make a missing file read as empty, for example `prior() { [ ! -f
  "$PRIOR_OUT" ] || sed -n "s/^$1=//p" "$PRIOR_OUT" | tail -n 1; }`, or only build `derived` when
  `PRUNE_BRANCHES` is non-empty.
- .github/actions/resolve-config/callers.test.mjs:4042: `publish()` always writes `priorOut`, even
  for the fresh case (`publish({ CLAIMED: "false" }, "")`), so no test exercises a missing
  `$PRIOR_OUT`. Add a case that runs the step under `bash -e` with no `$PRIOR_OUT` file and
  `CLAIMED=false`, and assert that it exits 0 with `checked=true` and an empty `prune-branches`.
- .github/scripts/work-resolve-issue/prune-branches.mjs:232: the spec names REST `DELETE
  /repos/{owner}/{repo}/git/refs/heads/{branch}`, but the PR deletes with a GraphQL `updateRefs`
  that pins `beforeOid`. It is still one request per branch and is safer against a concurrent push,
  so it is an acceptable departure, but a human should know.
- .github/workflows/work-resolve-issue.yml:727: the prune job runs after all three resolve-issue
  calls finish, not at the very start of the run. That still meets the 'after issue-prior'
  criterion. It uses `github.token` with `contents: write` and `pull-requests: read`, adds a
  10-minute timeout, and no other job's token is widened.
- I could not see the `if:` of the unchanged re-derive step in issue-fields. Confirm it runs
  whenever the `base` checkout runs, including when there is no claim and the prune list is
  non-empty. Otherwise prune lists from fresh runs are silently dropped.

This pull request leaves the merge queue, and ci-status holds it out while it carries pr-verify-failed. A pr-verify pass at a new commit removes the label.

@github-actions github-actions Bot added the pr-verify-failed pr-verify failed this head; a pass at a new commit removes it. label Oct 30, 2026
kyle-sexton and others added 2 commits October 30, 2026 06:46
A plain fresh issue run skips the re-derive, so prior() returned 1 on the
missing file and bash -e ended the publish step before checked=true.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot removed the pr-verify-failed pr-verify failed this head; a pass at a new commit removes it. label Oct 30, 2026
@kyle-sexton
kyle-sexton added this pull request to the merge queue Oct 30, 2026
Merged via the queue into main with commit d974b3d Oct 30, 2026
94 of 96 checks passed
@kyle-sexton
kyle-sexton deleted the fix/lane-stopped-branch-reuse branch October 30, 2026 11:05
@github-actions

Copy link
Copy Markdown
Contributor

Post-merge mutation testing

Killed 4, survived 3, timeout 0, no coverage 0, invalid 0, not run 0.
Covered-code score 57%; mutation score 57%.

File Killed Survived Timeout No coverage Invalid Not run
.github/scripts/work-resolve-issue/prune-branches.mjs 4 3 0 0 0 0

Surviving mutants

Location Operator Disposition Cause
.github/scripts/work-resolve-issue/prune-branches.mjs:28 other productive input-gap
.github/scripts/work-resolve-issue/prune-branches.mjs:61 LCR productive input-gap
.github/scripts/work-resolve-issue/prune-branches.mjs:71 ROR productive input-gap

The run's summary has the full report.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(pr-pipeline): reuse or delete a stopped lane run's branch when the issue re-runs

1 participant