Skip to content

fix(automerge): fail closed on unreadable manual-hold signals — retry×3 + cross-page timeline aggregation#122

Merged
topcoder1 merged 1 commit into
mainfrom
claude/automerge-hold-fail-closed
Jul 17, 2026
Merged

fix(automerge): fail closed on unreadable manual-hold signals — retry×3 + cross-page timeline aggregation#122
topcoder1 merged 1 commit into
mainfrom
claude/automerge-hold-fail-closed

Conversation

@topcoder1

Copy link
Copy Markdown
Owner

Note

Fast-follow to #121 (merged): the P2 deferred from its review — the manual-hold step's own fail-open reads. Builds directly on #121's retry() pattern and always() error-revoke step. Authored stacked on the #121 branch; rebased onto main (single commit, clean replay) after #121 click-merged mid-flight. Draft per repo policy: PRs to ci-workflows are always manual click-merge.

What

The Check manual hold step is the techrecon#405 defense: it keeps a human's explicit "do not auto-merge" decision (hold label, or a human gh pr merge --disable-auto) in force across workflow re-runs. Both of its reads were fail-open — … 2>/dev/null || echo "" degraded any API failure to "no hold" — so an API blip could defeat the human's hold under exactly the conditions (API misbehavior) where the defense matters most. The step's header even promised this as a feature ("degrades gracefully… can never crash the run").

The sibling classifier-verdict step already fails closed on an unreadable label set after 3 retries (#121, codex round-3 P1). This PR applies the identical contract to both hold signals:

  • Label read: retry() ×3 (5s/10s backoff) → on persistent failure, exit 1 with a ::error:: explaining re-run/manual-merge. Also now paginated (per_page=100 --paginate, same as the classifier read).
  • Timeline read: same retry×3 → fail closed. The jq lost its try — an unparseable body is an unknown hold state and fails closed, never open.
  • Already-armed PRs: steps.hold.outcome == 'failure' is wired into fix(automerge): close the pr-classify TOCTOU race — classify against base-branch risk-paths.yml at decision time #121's always() error-revoke step, so a failed hold read doesn't just refuse to arm — it disarms an arm a previous run already enabled. Without this, "fail closed" would only be half true.
  • The stale header-comment promise is rewritten with the fail-closed rationale (red run = one re-run or a manual click; silently dropped hold = unwanted merge of exactly the PR a human flagged).

Two silent-defeat defects found while hardening (both fixed + pinned)

  1. Unpaginated label read — default 30 labels/page; a hold label past position 30 read as absent.
  2. Per-page timeline aggregationgh api --paginate emits each page as its own top-level JSON array (gh api --help: "Each page is a separate JSON array or object"), and the old single-pass jq computed a per-page max, printing one verdict line per page. Multi-line output can never equal "yes", so any PR whose timeline crossed one page (30 events pre-fix — routine for busy PRs, which are the ones humans hold) silently reported "no hold" deterministically, blips aside. Reproduced with the exact production jq: newest event a human auto_merge_disabled on page 2 ⇒ output no\nyes ⇒ hold missed; same events in one array ⇒ yes. Now one jq pass over all pages (-s + add).

Also: both reads switched from ${{ github.repository }} interpolation to $GITHUB_REPOSITORY — behavior-identical in Actions, makes the run block extractable for the selftest, and removes a template-interpolation-in-script pattern.

Not changed

Hold semantics are untouched: label exact-match, actor filtering (bot self-revokes don't hold), chronology + same-second tie toward the human disable, label short-circuits timeline, timeline holds lift only by manual re-enable. All pinned by the new test against the shipped script.

Tests

New selftest/test_automerge_hold_gate.sh (24 checks) — awk-extracts the shipped run block (same pattern as test_automerge_riskfile_gate.sh) and drives it through a stubbed gh with failure-injection counters: label/timeline hold detection, near-miss label names, bot-vs-human actor, re-enable-lifts, same-second tie, multi-page aggregation in both directions, retry recovery (exactly 3 attempts, recovered value used), all fail-closed paths (label ×3, timeline ×3, 200-with-garbage), hold_label-disabled, empty timeline, plus structural pins (error-revoke wiring, pagination, no ${{ }} in the run block, stale promise gone). Wired into test_workflow_guards.py → enforced by tests-runner.

  • Red run against the unmodified step: structural pins + every fail-closed/pagination case fail (the old block isn't even executable standalone due to ${{ }}).
  • Green: all 24 pass. Full suite: uv run pytest -q10 passed. actionlint clean. (Prettier drift on this file pre-exists on the base branch and workflow yml isn't in CI's prettier scope — left untouched to keep the diff surgical.)

Residual

A persistent API outage now ends the run red with auto-merge disarmed — the PR waits for a re-run or a human click instead of merging past a hold. That's the intended direction of failure.

Auto-merge rationale: manual-merge category — PRs to ci-workflows are always click-merged, and this touches .github/workflows/** (production CI infra).
Codex rounds: 2 (both clean — zero findings; round 1: "No discrete regression introduced by this patch was identified", round 2 concurred)

🤖 Generated with Claude Code

…×3 + cross-page timeline aggregation

The Check-manual-hold step (the techrecon#405 defense: keep a human's
explicit one-click hold in force across workflow re-runs) read both of
its signals fail-open: `... 2>/dev/null || echo ""` degraded any label
or timeline read failure to "no hold", so an API blip could defeat the
human's hold under exactly the conditions where the defense matters.
The sibling classifier-verdict step already fails closed on an
unreadable label set (codex round-3 P1); this applies the same retry×3
then-fail-closed contract to both hold signals, and wires
steps.hold.outcome == 'failure' into the always() error-revoke step so
a failed read also disarms an arm a previous run already enabled.

Two additional silent-defeat defects fixed and pinned:

* The label read was unpaginated (30-label default page) — a hold
  label past position 30 read as absent. Now per_page=100 --paginate,
  same as the classifier read.
* The timeline read piped `gh api --paginate` (one top-level JSON
  array PER PAGE) into an aggregating per-input jq — a per-page max
  printing one verdict line per page, which the `= "yes"` comparison
  can never match. Reproduced: newest event a human disable on page 2
  ⇒ "no\nyes" ⇒ hold missed. Now one jq pass over all pages (-s, add),
  with no `try`: an unparseable body is an unknown hold state and
  fails closed.

New selftest/test_automerge_hold_gate.sh (24 checks) extracts the
shipped run block and pins label/timeline holds, actor + chronology +
tie semantics, multi-page aggregation both directions, retry recovery,
all fail-closed paths, and the error-revoke wiring; wired into
test_workflow_guards.py so tests-runner enforces it.

Co-Authored-By: Claude Fable 5 <[email protected]>
@github-actions

Copy link
Copy Markdown

Coverage Floor — mode: enforce

metric value
measured 100.0%
floor (current) 99.0%
target 100.0%
last bumped 2026-05-12

@topcoder1
topcoder1 marked this pull request as ready for review July 17, 2026 13:44
@github-actions github-actions Bot added the risk:blocked Risk class: blocked label Jul 17, 2026
@github-actions

Copy link
Copy Markdown

Risk class: blocked — manual merge required.

This PR touches one of the blocked path categories from .github/risk-paths.yml (Dockerfiles, docker-compose, .github/workflows/**, **/.env*, **/secrets*, infra/, terraform/, k8s/, or the classifier config itself).

Auto-merge is refused by claude-author-automerge.yml. A maintainer should review the diff and click "Squash and merge" themselves.

(This is a policy notice, not a code-quality failure. The classify job itself does not fail — required CI checks remain authoritative for "is the code green.")

@claude

claude Bot commented Jul 17, 2026

Copy link
Copy Markdown

No issues found. Fail-closed hold logic is sound (retry×3 → exit 1, blocked by implicit success() on enable + always() error-revoke wiring), cross-page jq aggregation is correct, and new branches are well-covered by the 24-check selftest.

@topcoder1
topcoder1 merged commit e8b02a6 into main Jul 17, 2026
17 checks passed
@topcoder1
topcoder1 deleted the claude/automerge-hold-fail-closed branch July 17, 2026 13:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk:blocked Risk class: blocked

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant