Skip to content

fix(standards-sync-stuck-automerge-alert): split the stuck-PR scan into a cheap page fetch and a per-candidate merge-state probe with retry - #234

Merged
kyle-sexton merged 3 commits into
mainfrom
fix/226-stuck-automerge-alert-query
Jul 24, 2026
Merged

fix(standards-sync-stuck-automerge-alert): split the stuck-PR scan into a cheap page fetch and a per-candidate merge-state probe with retry#234
kyle-sexton merged 3 commits into
mainfrom
fix/226-stuck-automerge-alert-query

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Closes #226

Summary

  • Splits the stuck-automerge scan into two phases so the hourly cron stops dying on GitHub's opaque server-side GraphQL error: phase 1 pages open PRs selecting only number/url/author/autoMergeRequest { enabledAt } (no mergeStateStatus), phase 2 probes mergeStateStatus one PR at a time via repository.pullRequest(number:), and only for armed, past-threshold bot PRs.
  • Wraps every GraphQL call in graphqlWithRetry — exponential backoff (GRAPHQL_RETRY_ATTEMPTS=4, GRAPHQL_RETRY_BASE_MS=1000, both step-env-tunable), rethrowing the last error on exhaustion so a persistent failure still fails the run loudly. On a throw the stuck-count output is never set, so the close-issue branch cannot fire a false all-clear.
  • Retains the existing manual-cursor pagination and MAX_PAGES soundness guard unchanged; "stuck" semantics are preserved (the BLOCKED check moved from the bulk page filter to the per-candidate probe).
  • No changes to the trigger surface, App-authored issue identity, uses: SHA pins, or permissions blocks.

Test plan

  • node --test .github/scripts/*.test.cjs (CI's exact command): 257/257 pass, including 24 in standards-sync-stuck-automerge-alert.test.cjs — new coverage asserts the page query never selects mergeStateStatus, exactly one phase-2 probe per candidate (no bulk fan-out), transient-error retry on both page fetch and probe, and persistent-error loud failure with no false all-clear.
  • actionlint on the changed workflow: clean.
  • biome check (CI config/scope): clean.
  • Post-merge follow-through: the phase-2 query shape is mock-validated only — confirm ≥2 consecutive green scheduled runs against the real fleet (the issue's remaining acceptance criterion).

Related

🤖 Generated with Claude Code

kyle-sexton and others added 2 commits July 23, 2026 18:29
…to a cheap page fetch and a per-candidate merge-state probe with retry

The single-page query selected mergeStateStatus across a 50-node page, which
forces an on-demand mergeability computation per PR server-side and reliably
returns an opaque GraphQL error, failing every scheduled run. Page fetches now
select only the fields needed to identify armed, past-threshold bot PRs;
mergeStateStatus is fetched one candidate at a time. Every GraphQL call retries
with exponential backoff so a transient server error no longer aborts the scan
and falsely clears the tracking issue; a persistent failure still propagates and
fails the run loudly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GsyNHuFaUowCwAyGWDT55N
…e two-phase scan tests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GsyNHuFaUowCwAyGWDT55N
@claude

claude Bot commented Jul 23, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 41s —— View job


PR Review

  • Gather context: read PR diff and changed files
  • Review workflow YAML changes
  • Review test file changes
  • Assess correctness, security, and CLAUDE.md alignment
  • Post findings

Summary

This is a solid fix to a real operational problem. The two-phase split is the right architectural move: removing mergeStateStatus from the bulk page query eliminates the root cause of GitHub's opaque server-side errors, and graphqlWithRetry provides the belt-and-suspenders defense for the narrower per-PR probes. No correctness bugs, no security issues, and full CLAUDE.md alignment.


Correctness

Retry logic — the off-by-one is correct:

for (let attempt = 1; ; attempt += 1) {
  try {
    return await github.graphql(query, variables);
  } catch (error) {
    if (attempt >= retryAttempts) throw error;   // throws on attempt 4 (with retryAttempts=4)
    ...
    await sleep(delayMs);
  }
}

With retryAttempts=4: attempts 1–3 retry (delays 1 s, 2 s, 4 s), attempt 4 throws. Total time for a fully failing call: ~7 s — well within the 15-minute timeout.

False all-clear prevention is structurally sound: core.setOutput('stuck-count', ...) is placed after both loops. A throw from graphqlWithRetry propagates out of the script function, the github-script step fails, and subsequent steps — including the close-issue branch (if: steps.scan.outputs.stuck-count == '0') — are skipped by GitHub Actions. The output is never set, so == '0' is false. This is verified end-to-end by the persistent-failure test.

Candidate scopingconst candidates = [] is declared inside the for (const repo of repoNames) loop, so it correctly resets per-repo. No cross-repo leakage.

Phase-2 probes are sequentialfor ... await over candidates is intentional and appropriate: this workflow is a low-frequency watchdog (hourly cron), the expected candidate count is tiny (ideally zero), and sequential probes reduce concurrent load on GitHub's mergeability computation — the very thing that was tripping the original error.

One minor observation — the ?? 4 / ?? 1000 fallbacks in:

// .github/workflows/standards-sync-stuck-automerge-alert.yml, line 141–142
const retryAttempts = Number(process.env.GRAPHQL_RETRY_ATTEMPTS ?? 4);
const retryBaseMs   = Number(process.env.GRAPHQL_RETRY_BASE_MS ?? 1000);

are dead code in the Actions runtime: both vars are declared in the step's env: block directly above, so they are always strings, never undefined. This is harmless and might be intentional defensive coding; leaving it is fine.


Security / CLAUDE.md alignment

Trigger surface: unchanged — still workflow_call only; no pull_request_target or workflow_run. ✓

SHA pins: no uses: lines were touched. ✓

Permissions: no changes to the job-level contents: read grant or the issue-token scope. ✓

Log hygiene — the new core.info() in the retry path:

// line 159
core.info(`GraphQL ${description} failed (attempt ${attempt}/${retryAttempts}): ${error.message}; retrying in ${delayMs}ms.`);

logs error.message from GitHub API responses in a private workflow run (this is a scheduled reusable, not a consumer of fork PRs). No token echoing, no display_report/show_full_output surface. Acceptable.

GraphQL variables: queries use parameterized $owner, $repo, $after, $number. The only string interpolation is first: ${PAGE_SIZE} (numeric constant 50). No injection risk.


Test coverage

The new tests directly match the invariants the PR claims to establish:

Test What it locks in
page query never selects mergeStateStatus Structural regression guard on the query shape
only armed, past-threshold bot PRs are probed No bulk fan-out (the perf contract)
transient error on page fetch is retried Retry path for phase 1
transient error on probe is retried Retry path for phase 2
persistent error fails loudly, no false all-clear The safety invariant

The GRAPHQL_RETRY_BASE_MS: "0" trick in the test harness is clean — it exercises the retry code path without actually sleeping.

One note on the "queries every manifest-derived target repository" test: it maps all graphqlCalls to .variables.repo (not filtered to page calls). It works here because the test data is empty and produces no candidates — but if someone later adds armed PRs to this test, the extra probe calls (which also carry variables.repo) would cause deepEqual to fail with a duplicated repo entry. The test's intent remains valid; just worth a comment if it ever needs to be extended.


Verdict

Approve. The fix is correct, secure, well-tested, and CLAUDE.md-compliant. The one remaining acceptance criterion from the PR description (≥2 consecutive green scheduled runs against the real fleet) is appropriately flagged as post-merge follow-through.

@claude

claude Bot commented Jul 23, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 2m 16s —— View job


Security Review

  • Gather PR diff and changed files
  • Review workflow YAML for security issues
  • Review test file for security issues
  • Analyze data-flow, trust boundaries, and logic for vulnerabilities
  • Post findings

Scope: .github/workflows/standards-sync-stuck-automerge-alert.yml and .github/scripts/standards-sync-stuck-automerge-alert.test.cjs — changes in this PR only.

Summary

No critical or important security issues found. The change is security-positive overall: the split-phase design reduces the attack surface of each GraphQL call, and the false-clear protection is correctly preserved — stuck-count is set only after all scans complete, so a throw mid-scan leaves the output unset, neither the update nor the close branch fires, and the run fails loudly.

One low-confidence suggestion follows.


SUGGESTION — error.message from GitHub API response written to stdout without workflow-command escaping

Confidence: LOW

Line 159:

core.info(`GraphQL ${description} failed (attempt ${attempt}/${retryAttempts}): ${error.message}; retrying in ${delayMs}ms.`);

@actions/core's core.info() writes to stdout without escaping workflow command sequences. If a GitHub GraphQL error response contained text that looks like a runner workflow command (e.g., ::error::, ::warning::, ::set-output name=...::value), the runner would interpret it. The error.message here is sourced entirely from GitHub's own API error response, so controlling its content requires compromising GitHub's servers — not a practical attacker model. The risk is negligible in this context, but worth noting as a pattern to avoid in workflows where the error source is less trusted.

No remediation is required for this PR. If hardening is desired, the content could be sanitized before logging (e.g., replacing :: sequences), though that adds complexity with no real threat to defend against here.


Other checks (all clean)

Check Result
on: trigger surface Unchanged; no dangerous pull_request_target or workflow_run with secrets introduced
permissions: blocks Unchanged; narrowest-needed grants preserved
New uses: entries None introduced
Script injection via ${{ }} in run: New env vars GRAPHQL_RETRY_ATTEMPTS/GRAPHQL_RETRY_BASE_MS are literal string constants, not expressions over user-controlled context
GraphQL query construction Both listQuery and mergeStateQuery use parameterized typed variables ($repo: String!, $number: Int!); no interpolation into query text
False-clear invariant core.setOutput('stuck-count', ...) is only reached after the full scan loop; a throw anywhere in the loop leaves the output unset, so the close-issue branch is unreachable on failure
Token scope permission-pull-requests: read App token unchanged; issue-write token scope unchanged
GRAPHQL_RETRY_ATTEMPTS as attack surface Set to the literal '4' in step env:, not derived from any workflow inputs: or context expression; callers cannot tune it to trigger unbounded retry loops

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2ed4293b87

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread .github/workflows/standards-sync-stuck-automerge-alert.yml
@kyle-sexton

Copy link
Copy Markdown
Contributor Author

AI reply (work-loop lane). Review-finding classification:

Source Finding Classification Disposition
claude PR review ?? 4 / ?? 1000 fallbacks are dead code in Actions runtime VALID (informational) Reviewer: "leaving it is fine" — kept as defensive coding, no change
claude PR review repo-mapping test would need call filtering if armed PRs added to fixture VALID (informational) No change now; the incoming TOCTOU-fix commit extends probe tests and keeps that fixture unambiguous
claude security review error.message logged via core.info without workflow-command escaping (LOW) VALID (no remediation) Reviewer's own verdict: content is GitHub-API-sourced, not a practical attacker model; sanitization adds complexity with no real threat — not filed as follow-up per that recommendation
codex P2 (inline) Revalidate auto-merge state in per-candidate probe VALID (fix now) Fix in flight; follow-up with commit SHA on the thread

…om the per-PR probe to close the page-vs-probe race

Auto-merge can be disarmed or re-armed in the interval between the phase-1 page
fetch and the phase-2 merge-state probe, so trusting the page's
autoMergeRequest.enabledAt could report a disarmed PR as stuck or report a stale
armed duration for a freshly re-armed one. The probe now also selects
autoMergeRequest { enabledAt }; the stuck decision re-checks armed state and the
threshold against that fresh value and computes armedHoursAgo from it, skipping a
PR that is no longer armed or is now armed under the threshold.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GsyNHuFaUowCwAyGWDT55N
@claude

claude Bot commented Jul 23, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 1m 37s —— View job


Review of TOCTOU fix commit (07f706e)

  • Gather context: identify the new commit scope
  • Diff latest commit against prior state
  • Verify probe re-fetches autoMergeRequest { enabledAt } and re-validates armed + threshold
  • Verify tests cover disarmed-between-phases and probe-fresh-duration scenarios
  • Post findings

What changed

The fix addresses the Codex P2 finding: a PR could be disarmed (or re-armed) in the window between the phase-1 page fetch and the phase-2 probe, causing the stuck decision to use a stale enabledAt from the page. Two things changed.

Workflow script (24 lines, 2 files):

  1. Extracted armedHoursSince(enabledAt) — returns null for falsy input, a number otherwise. Used in both isArmedCandidate (page filter) and the probe post-processing below, eliminating the prior duplicate elapsed-time calculation.

  2. Added autoMergeRequest { enabledAt } to mergeStateQuery — the probe now returns fresh armed state alongside mergeStateStatus.

  3. The stuck decision at the probe site re-validates entirely from probe-fresh data:

    const pullRequest = result.repository?.pullRequest;
    if (pullRequest?.mergeStateStatus !== 'BLOCKED') continue;
    const armedHours = armedHoursSince(pullRequest.autoMergeRequest?.enabledAt);
    if (armedHours === null || armedHours < thresholdHours) continue;
    stuck.push({ ..., armedHoursAgo: Math.floor(armedHours) });

    Three cases now handled correctly:

    • Disarmed between phases: autoMergeRequest is null → armedHoursSince(undefined) returns null → skipped.
    • Re-armed under threshold between phases: fresh enabledAt is recent → armedHours < thresholdHours → skipped.
    • Re-armed over threshold: reports using fresh enabledAt, not the stale page value.

Null-probe safety — if the PR was deleted or returned null from the probe, pullRequest?.mergeStateStatus is undefined !== 'BLOCKED' → skipped immediately, no attempt to read autoMergeRequest. Correct.


Tests (92 new lines in standards-sync-stuck-automerge-alert.test.cjs)

The test harness gains a probeOverrides parameter to runScan(). When probeOverrides[number] is present, the mock uses the override's enabledAt/mergeStateStatus rather than echoing the page node — cleanly simulating divergence between page and probe without requiring separate fixtures. Backward-compatible (default {}).

Three new tests covering all TOCTOU scenarios:

Test Scenario Assert
disarmed between page fetch and probe probeOverrides: { 8: { enabledAt: null } } stuck-count === '0', report === null
duration computed from probe-fresh enabledAt page says 20h, probe says 6h report matches /| 6h |/, not /20h/
re-armed under threshold between page and probe page says 20h (over), probe says 1h (under) stuck-count === '0', report === null

The "enabledAt" in override check (not just override.enabledAt) is the right way to distinguish "explicitly set to null" from "not present" — correctly handles the disarm case where null is a meaningful value.


No regressions introduced

  • isArmedCandidate behavior is unchanged — the refactor to armedHoursSince is equivalent to the previous inline arithmetic.
  • BLOCKED check remains first in the probe post-processing, so non-blocked PRs still skip without touching enabledAt.
  • armedHoursAgo: Math.floor(armedHours) is computed from the same armedHours variable used for the threshold check — single source of truth.
  • False-all-clear prevention and retry logic are untouched.

Verdict

Approve. The fix is correct, complete, and well-tested. All three TOCTOU scenarios are covered, the helper extraction is a genuine DRY improvement (not incidental cleanup), and the probeOverrides harness mechanism is clean without being over-engineered. No issues found.

@claude

claude Bot commented Jul 23, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 3m 23s —— View job


Security Review

  • Gather context
  • Read changed files (workflow YAML + test script)
  • Analyze for logic flaws, data-flow, trust-boundary issues
  • Post findings

Scope: .github/workflows/standards-sync-stuck-automerge-alert.yml and .github/scripts/standards-sync-stuck-automerge-alert.test.cjs — changes in this PR, including the TOCTOU fix in commit 07f706e.

No critical or important security findings.


TOCTOU fix verification (07f706e)

The codex P2 finding is correctly resolved. The phase-2 probe now fetches autoMergeRequest { enabledAt } alongside mergeStateStatus, and the stuck decision re-validates both armed state and threshold from that fresh value:

// https://github.com/melodic-software/ci-workflows/blob/07f706eb6e9a101a032ba310372c842c66139cfd/.github/workflows/standards-sync-stuck-automerge-alert.yml#L249-L253
const pullRequest = result.repository?.pullRequest;
if (pullRequest?.mergeStateStatus !== 'BLOCKED') continue;
const armedHours = armedHoursSince(pullRequest.autoMergeRequest?.enabledAt);
if (armedHours === null || armedHours < thresholdHours) continue;
stuck.push({ repo, number: candidate.number, url: candidate.url, armedHoursAgo: Math.floor(armedHours) });

The phase-1 value (candidate.url) carried into the report is cosmetic (a display link), sourced from GitHub's own API (https://github.com/owner/repo/pull/N format), and carries no correctness or security weight. The stuck decision depends only on probe-fresh data. The fix tightens the boundary correctly — no new attack surface introduced.


New code introduced — graphqlWithRetry

Lines 152–163

Retry cap is caller-isolated. GRAPHQL_RETRY_ATTEMPTS and GRAPHQL_RETRY_BASE_MS are set as literal string constants in the step env: block, not derived from any workflow_call input or github context expression. Callers cannot influence them to force an unbounded retry loop or a DoS-by-backoff.

Backoff arithmetic is safe. With retryAttempts=4, the maximum exponent is 2**(3-1)=4 (1 s, 2 s, 4 s total sleep, 7 s worst-case for a failed call). No overflow; well within the 15-minute job timeout.

error.message logging — previously noted at LOW confidence by the prior review. The source is GitHub's own API error responses; controlling their content requires compromising GitHub's servers. No remediation needed here; no new surface introduced by this PR.


Phase boundary data-flow

Phase-1 → phase-2 data crossing the boundary:

Field Type Source Used as
candidate.number Int! (GraphQL typed) GitHub API GraphQL $number variable in phase-2 probe
candidate.url string GitHub API Cosmetic display link in Markdown report

Neither field is string-interpolated into query text. candidate.number is passed as a typed GraphQL variable ($number: Int!). No injection path.

The stuck array entries contain only hardcoded strings, GitHub-API-sourced integers and URLs, and Math.floor(armedHours) (integer). The Markdown report wraps repo in backticks and uses integer values elsewhere. No injection surface in the report body.


Other checks (clean)

Check Result
on: trigger surface Unchanged; workflow_call only
permissions: Unchanged; contents: read on the job
New uses: entries None
${{ }} expressions in run: None new; MANIFEST env-var pattern (pre-existing, correct)
GraphQL query construction Both queries use parameterized typed variables; only first: ${PAGE_SIZE} (numeric constant 50) is interpolated
False-clear invariant core.setOutput('stuck-count', ...) is only reached after the full scan loop; a throw anywhere leaves the output unset
Retry-amplified false-clear graphqlWithRetry rethrows on exhaustion — the throw propagates, scan step fails, close-issue branch is unreachable
candidates array scope Declared inside for (const repo of repoNames) — resets per-repo, no cross-repo leakage

No security issues to flag. The change is security-neutral to positive: the split-phase design reduces each query's attack surface, the TOCTOU fix is correctly scoped to probe-fresh data only, and the retry wrapper introduces no new caller-reachable surface.

This was referenced Jul 23, 2026
@kyle-sexton
kyle-sexton merged commit 42329ef into main Jul 24, 2026
37 checks passed
@kyle-sexton
kyle-sexton deleted the fix/226-stuck-automerge-alert-query branch July 24, 2026 00:29
kyle-sexton added a commit that referenced this pull request Jul 24, 2026
…ody gate (#240)

## Summary

Plugin-created PRs in this repo trip the `pr-issue-linkage` gate because
the
source-control plugin's portable default PR body scaffolds only `##
Summary`
and `## Test plan`, omitting the `## Related` section this repo
requires.

This declares the team-tracked `pr_body_required_sections` key in
`.claude/source-control.md` — the plugin's designed per-repo seam
(`reference/config-resolution.md`) — so `/source-control:pull-request
create`
both drafts a `## Related` section and pre-checks it before `gh pr
create`.
The key is a **closed list** that replaces the plugin's default
wholesale, so
the portable `Summary` / `Test plan` sections are re-declared alongside
`Related`.

The closing-keyword / `No linked issue` half of the gate is a separate,
independent mechanism the plugin already satisfies natively (its create
logic
always emits a `Closes #N` line or a `No related issue:` opt-out
marker); it is
not expressible through this heading-only key, so no attempt is made to
encode
it here.

## Test plan

- No executable behavior changes; this is a plugin-config data file.
- This PR's own body is the live verification: it carries `## Summary`,
`## Test plan`, and a non-empty `## Related` section plus the `No linked
issue` marker, so the `pr-issue-linkage / pr-issue-linkage` required
check
  must pass on it.

## Related

No linked issue.

This closes no GitHub issue; it is a convention-config change. It exists
because the gate previously tripped on plugin-composed bodies: PR #234
in this
repo and dotfiles #301 in a sibling repo both failed the same
closing-keyword-plus-`## Related` validation on creation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_0169XBydnqC5S6bkHDz1TDwL

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Jul 29, 2026
…never armed (#291)

## Summary

`standards-sync.yml` arms squash auto-merge on sync PRs. The mutation is
wrapped
in a `try`/`catch` that downgrades **every** rejection to
`core.warning`, so an
arming failure produces a sync PR that simply sits unarmed —
indistinguishable
from the pre-arming status quo, and invisible to a watchdog that only
hunted PRs
stuck **with** auto-merge armed. Failing closed is correct; failing
closed
**invisibly** is the defect, because the operator's mental model says
"armed"
while the PR waits on a human who was never told to look.

This PR closes that blind spot, and fixes the arming gate that would
otherwise
have made the new detection fire on PRs where arming was deliberately
skipped.

### Today's failure behavior, from source

`.github/workflows/standards-sync.yml` at `ac223bb`, the arming step's
tail:

```js
} catch (error) {
  // Known rejection: a PR that is already immediately mergeable
  // (GraphQL mergeStateStatus CLEAN) has nothing to wait for, and
  // GitHub's mutation errors instead of no-op succeeding. Any
  // other transient rejection is handled the same way: log and
  // let the sync continue, never fail the run over arming.
  core.warning(
    `Could not arm auto-merge on ${owner}/${repo}#${pull_number}: ${error.message}`,
  );
}
```

The failure is **trapped**. The step carries no `continue-on-error` and
does not
need one — the `catch` swallows the throw, so the step succeeds
regardless, and
nothing downstream reads the outcome.
`standards-sync-automerge-arm.test.cjs`
pinned this deliberately ("a rejected mutation ... is logged and
swallowed, not
thrown").

### Can a GitHub App installation token arm auto-merge?

Researched against official GitHub sources, not recall. **Yes** —
established by
a chain, because no single page states it:

1. `GITHUB_TOKEN` **is** an App installation access token — github/docs
`content/actions/concepts/security/github_token.md`: *"The
`GITHUB_TOKEN`
   secret is a GitHub App installation access token."*
2. GitHub documents a workflow enabling auto-merge with exactly that
token —

`content/code-security/tutorials/secure-your-dependencies/automate-dependabot-with-actions.md`
   runs `gh pr merge --auto` under `permissions: contents: write` +
   `pull-requests: write`.
3. `gh pr merge --auto` issues this mutation — `cli/cli`
`pkg/cmd/pr/merge/http.go`: `graphql:"enablePullRequestAutoMerge(input:
$input)"`. First-party **source**, not documentation; flagged as such.

The App behind `GITHUB_TOKEN` is GitHub's own Actions App, and docs
state no
divergence for third-party Apps in either direction.

Not settled by documentation, and marked as such rather than inferred
past:

| Question | Verdict |
| --- | --- |
| Minimal permission for the mutation | **UNVERIFIED** — no GraphQL
per-mutation permissions table exists.
`content/apps/.../choosing-permissions-for-a-github-app.md` explicitly
punts: *"you should test your app to ensure that it has the required
permissions."* The sync token already requests the `contents: write` +
`pull-requests: write` pair from GitHub's own working example. **Would
settle it:** a throwaway repo, arming with each permission alone and
then both, recording `errors[].type`. |
| Does the mutation error rather than no-op when the PR is already
mergeable? | **UNVERIFIED** — the GA changelog says auto-merge *"can
only be enabled ... when there are unsatisfied merge requirements"*, but
never states the API errors. **Would settle it:** same throwaway repo, a
PR with zero unsatisfied requirements. |

Both experiments mutate state, so a disposable repo is the ceiling —
never the
live fleet.

**Verified preconditions:** *"Before you use auto-merge, it must be
enabled for
the repository"* (`allow_auto_merge`, default `false`), and *"People
with write
permissions to a repository can enable auto-merge for a pull request."*

## The fix

### Part 1 — the watchdog reports never-armed sync PRs

`standards-sync-stuck-automerge-alert.yml` gains a second category
beside
armed-but-BLOCKED: a sync PR past `threshold-hours`, in a target the
manifest
marks `automerge: true`, on which auto-merge was **never** armed. On
detection it
takes the path the existing category already takes — a marker-deduped
tracking
issue in the calling repo, and `exit 1` so the *scheduled* run fails and
notifies. That is what makes the failure observable rather than merely
logged.

The discriminator is the **absence of any auto-merge *enabled* event**
in the
PR's timeline. That is direct positive evidence the mutation never
succeeded. The
first draft of this PR keyed on the absence of an
`AutoMergeDisabledEvent`
instead, which is only an inference and conflates two different things:
GitHub
disables auto-merge on its own when someone without write access pushes
to the
head branch or the base is switched, and the event's `reason` /
`reasonCode` are
free-form `String` in the published schema, not an enum, so they cannot
be keyed
on. An enabled event answers the question that actually matters — *did
arming
ever take?*

**Two GraphQL semantics had to be established live, and both were wrong
in an
intermediate revision of this PR.** Each independently inverts the
check, so in a
live run they would have masked each other:

- `timelineItems.totalCount` reports the **whole** timeline and ignores
  `itemTypes` — only `nodes` is filtered. Verified on `github-iac#234`:
`totalCount: 4` alongside zero matching nodes. Reading `totalCount`
makes every
PR look already-armed, which would have stopped the sync arming anything
at all
  and made the watchdog exonerate every real failure.
- `mergeMethod: SQUASH` records an **`AutoSquashEnabledEvent`**, not an
`AutoMergeEnabledEvent`. Verified on `medley#1619` (armed, `mergeMethod:
  SQUASH`): zero `AUTO_MERGE_ENABLED_EVENT` nodes, one
  `AutoSquashEnabledEvent`.

Both workflows now read `nodes` across all three enabled-event types
(`AUTO_MERGE_` / `AUTO_SQUASH_` / `AUTO_REBASE_ENABLED_EVENT`), so a
future
merge-method change cannot silently blind the check. `first: 1` is safe
because
GitHub applies the `itemTypes` filter *before* pagination (verified: an
event at
raw timeline index 2 is still returned by `first: 1`).

Hand-written mocks are structurally unable to catch this class of defect
— they
encode whatever semantics the author believed. So the mocks now model
the real
behavior (filtered `nodes` plus a non-zero `totalCount` decoy the
production code
must not read), and four contract tests pin both facts directly against
the
shipped query text.

### Part 2 — arming is self-healing, which is the root cause

Arming was gated on `steps.cpr.outputs.pull-request-operation ==
'created'`.
`peter-evans/create-pull-request` at the pinned SHA sets
`pull-request-number`
whenever the branch differs from base, but only ever reports `created`
once
(verified in `src/create-pull-request.ts` at
`5f6978faf089d4d20b00c7766989d076bb2fc7f1`). So a sync PR opened while
its target
carried `automerge: false` — how a rollout window is held — **stayed
unarmed
forever**, and lifting the opt-out would not have repaired it.

Two such PRs are open on the fleet right now: `github-iac#234` and
`medley#1665`,
both created 2026-07-27, both unarmed with an empty *arming* timeline
(they do
carry ordinary timeline items — that distinction is the subject of the
next
section). Under the created-only gate they would have been reported as
arming
failures every hour, permanently, and sent the operator to a warning
line the
*skipped* step never wrote.

So the gate is now "this PR exists, the manifest says arm it, and it has
never
been armed" — the same predicate the watchdog uses. Consequences, stated
up
front:

- Once the standards sync-engine pin carries this change, the next sync
after the
manifest restores `automerge: true` arms every open sync PR fleet-wide.
That is
the intended end state, and it repairs #234 and #1665, which today will
never
self-merge and which nothing is watching. **Sequencing matters:** if the
manifest is restored while the engine pin still predates this change,
those PRs
stay unarmed and a re-pinned watchdog reports them permanently. The
engine
  re-pin should target this PR's merge SHA, not `ac223bb`.
- Given the right order, a **transient** alert is still possible if the
hourly
watchdog fires in the gap between the manifest flip and the next sync.
Standards'
`sync.yml` triggers on push to `main`, so the flip is itself the trigger
and the
gap is minutes. It self-resolves via the existing `Close recovered
tracking
issue` step — a bounded transient, not the permanent hourly alarm this
removes.
- The recovery text now covers both cases: an arming step that ran and
was
rejected leaves a warning to read; a caller pinned to an engine that
predates
arming on already-open PRs skips the step entirely, and the text names
the
re-pin as that fix rather than pointing at a log line that does not
exist.
- A reviewer who disarms a PR to hold it back is still never overridden.
Verified live on `medley#1613`: disarming does **not** erase the enabled
event.

The single GraphQL read also replaces the REST `pulls.get` the step used
to fetch
the node id.

### Rejected alternatives

**Rejected — make the arming step fail its leg.** The step's own comment
records
that an already-mergeable PR is a *known benign* rejection, and the docs
research
above leaves the mutation's error-vs-no-op semantics **UNVERIFIED**.
Failing the
sync leg would turn a benign, undocumented condition into a red sync,
and the
token already requests exactly GitHub's documented working permission
pair, so a
permission wall is not the likely failure mode. A post-condition check
("did the
PR end up armed?") was considered and folded into Part 2 instead, where
it costs
nothing and repairs rather than merely reports.

**Rejected — emit a distinguishable annotation.** `core.error` instead
of
`core.warning` colors the log, but nothing reads sync logs on a schedule
— which
is precisely why the stuck-PR case needed a watchdog rather than louder
logging.
Louder, not observable.

**Rejected — a new dedicated workflow.** It would duplicate the manifest
read,
the App-token mint, the pagination-with-retry, the marker-deduped
tracking issue,
and the decoy-resistant issue adoption this workflow already has, then
file a
second competing issue for one incident. Both conditions answer the same
question
("can this sync PR merge itself?") and belong in one report.

## Activation dependency — this ships inert

The watchdog is a reusable workflow. Exactly **one** `uses:` pin exists
across the
default branches of every org repo the token can see (`gh search code`
returns 13
mentions across 7 repos; every other hit is prose or a materialized copy
of
standards' governance data, not a `uses:`):
`melodic-software/standards`
`.github/workflows/standards-sync-stuck-automerge-alert.yml:19`,
pinned at `43bc8d0`. `components/runner-policy/policy.json:438` carries
the same
SHA as governance data. Until that caller is re-pinned, the never-armed
detection
never runs. Reachability also needs the manifest's Phase 3d `automerge:
false`
window to close.

The sync-engine pin is the second axis: standards' `sync.yml` pins
`0b45b9f`,
which has no arming step at all, so the self-healing arm is equally
latent until
that is re-pinned past this PR.

This is the same second-staleness axis already recorded for the sync
engine's own
pin.

## Scope: the `43bc8d0` → `42329ef` re-pin is split out

Not because it is unrelated — `42329ef` (#234) hardened the very scan
loop this
PR extends — but because:

- The pin lives in `melodic-software/standards`, not this repository
(`git grep
43bc8d0` finds zero hits here), and standards is outside this work's
write
  fence.
- `42329ef` is no longer the right target. Once this PR merges, the
caller needs
the SHA that carries **this** change, which supersedes `42329ef`
entirely.

It belongs in the Phase 3g re-pin sweep, aimed at this PR's merge SHA.

## Deferred, with trigger

Auto-merge that was armed and later fell off by itself (a push from
someone
without write access, a base-branch switch) is deliberately **not**
detected —
that PR carries an enabled event and is exonerated by both the watchdog
and the
self-healing arm. It is a different failure from the arming blind spot
this PR
closes. **Trigger to revisit:** an armed sync PR observed silently
reverting to
unarmed on the live fleet.

## Test plan

All counts below regenerated from the commands shown, not from memory.

- `node --test .github/scripts/*.test.cjs` (CI's exact command, from
  `ci.yml:373`): **304 pass, 0 fail.**
- `standards-sync-stuck-automerge-alert.test.cjs`: **40 pass** (27 on
  `origin/main`, so 13 new).
- `standards-sync-automerge-arm.test.cjs`: **10 pass** (5 on
`origin/main`, so 5
  new).
- `actionlint 1.7.12` on both changed workflows: clean, exit 0.
- `npx @biomejs/biome@2.5.4 ci
--config-path=fixtures/typescript/good/biome.json
--error-on-warnings fixtures/typescript/good .github/scripts`: clean,
exit 0.
  (A previous revision of this PR failed this check; the config lives at
`fixtures/typescript/good/biome.json`, which an earlier note wrongly
said did
  not exist.)
- `markdownlint-cli2@0.23.1 README.md`: 0 issues.

The tests execute the shipped code, not a re-implementation:
`extractScanScript`
/ `extractArmingScript` read the actual `.yml`, slice the `script: |`
block by
step name, and run it through `AsyncFunction`. Only `github.graphql` and
`core`
are mocked.

New coverage:

- detection past threshold; no alarm inside threshold
- a PR armed-then-disarmed exonerated, with the probe proven to have run
- an opted-out target never probed at all
- a PR armed, merged, or closed in the page→probe race
- non-sync authors ignored
- a persistent probe error failing loudly with no false all-clear
- both categories reported in separate sections; all-clear requires both
empty
- arming skipped for a currently-armed PR and for an armed-then-disarmed
PR
- an unreadable PR warns instead of mutating
- contract tests, in both files, pinning that the arming history is read
from
filtered `nodes` and never `totalCount`, and that all three merge
methods'
  enabled events are probed

## Related

- Refs #213 — the arming step and this watchdog's original half.
- Refs #234 (`42329ef`) — the scan-loop split and retry this change
extends.
- Refs melodic-software/standards#289 — the engine re-pin that first
puts the
  arming step into production.

No linked issue.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Jul 29, 2026
## Summary

Pre-step (0) (merged in #301) states that re-pinning the standards sync
engine
to `8202e03f` also activates the never-armed watchdog, "so ONE pin
satisfies
both." The commit does carry both halves; **standards does not consume
it that
way**, so the guidance is wrong for the operator who follows it.

Verified against the live repos:

| Claim | Reality |
|---|---|
| One pin activates arming + watchdog | Two separate callers, two
separate pins |
| Watchdog rides `sync.yml`'s pin | Watchdog is
`standards-sync-stuck-automerge-alert.yml`, a different reusable |

```
standards/.github/workflows/sync.yml:33
  → standards-sync.yml@8202e03f…                      (standards#293, in flight)
standards/.github/workflows/standards-sync-stuck-automerge-alert.yml:19
  → standards-sync-stuck-automerge-alert.yml@43bc8d0f  # 43bc8d0 2026-07-22
```

Re-pinning `sync.yml` therefore activates the **arming half only**.

`43bc8d0` predates **two** commits, not one — `git log --oneline
43bc8d0..8202e03 --
.github/workflows/standards-sync-stuck-automerge-alert.yml`:

- `8202e03` #291 — never-armed detection
- `42329ef` #234 — split the stuck-PR scan into a cheap page fetch +
per-candidate merge-state probe with retry

**The deadline the old text implied was already met.** The never-armed
scan
selects targets with `jq '[.include[] | select(.automerge) |
.repo_name]'` and
gates candidates on `automergeRepoNames.has(repo)`, so it is inert while
the
rollout window holds all 8 targets at `automerge: false` — and becomes
load-bearing at the exact moment `automerge: true` is restored. The
watchdog
re-pin must land **before** that restore.

**One nuance the correction records**, because it cuts against reading
the
stale pin as wholly dormant: the *armed-but-stuck* scan is **not**
automerge-gated. It loops `repoNames` (all 8 targets) at `:299` and
pushes
`isArmedCandidate` matches at `:315` with no automerge check — only
`:316`'s
never-armed branch is gated. It sweeps every target on the hourly cron
today,
and is quiet only because no sync PR is currently armed (checked:
dotfiles#361,
provisioning#231, github-iac#244, medley#1676 all report
`autoMergeRequest:
null`).

No phase tag advances. Scope is the pre-step (0) paragraph only.

## Test plan

- `markdownlint-cli2 docs/topics/claude-review-lanes/PLAN.md` →
`Summary: 0 error(s)`
- `git diff -U0 | grep -E "^[+-].*\[(DONE|DOING|TODO)\]"` → no matches
(no phase tag touched)
- Every factual claim above regenerated from a command against the live
repos,
  on a **full** clone — the local clone was shallow, which can make
  `git diff <sha>^ <sha>` error in a way that reads as a false positive;
`git fetch --unshallow` was run and every finding re-derived. All held.
- Independently re-verified by a fresh-context verifier (verdict in the
PR thread).

## Related

No linked issue. Corrects text merged in #301; describes work delivered
by #291
and #234. The paired standards-side watchdog re-pin ships as its own PR.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

standards-sync-stuck-automerge-alert: every scheduled run fails — bulk mergeStateStatus GraphQL query errors server-side

1 participant