Skip to content

fix(ci): an EMPTY check set must never arm auto-merge (#248) - #249

Merged
github-actions[bot] merged 1 commit into
mainfrom
feat/empty-check-set-248
Oct 24, 2026
Merged

github-actions[bot] merged 1 commit into
mainfrom
feat/empty-check-set-248

Conversation

@aitrawog

Copy link
Copy Markdown
Contributor

Summary

The Head check-runs step of the reusable charly/pr-validator workflow — the merge guardrail that arms native auto-merge — could be satisfied by observing nothing. Its snapshot's guard is on the body: [[ -n "$body" ]]. A well-formed API answer that reports ZERO check runs is not an empty body, so it passes that guard; the jq filter yields {pending:[],bad:[],all:[],total:0}; every count reads 0; and the step fell through to

all 0 check run(s) on this head passed — safe to arm auto-merge

with exit 0. The next step, "Enable auto-merge", then ran on a head whose checks the gate had never observed. This is the same fail-open shape #247 removed for the empty body, one level down.

The fix — the guard now also covers the SET. A total of 0 is its own class, and it refuses to arm. The exit code is the class, as it is for the four classes #247 introduced:

exit class what it means remedy
0 GREEN every check on the head passed arm it
1 NOT-PASSED a check on the head did not pass — a FINDING about the change fix it and push
2 IN-FLIGHT a check is still RUNNING — not a finding re-run this job only
3 UNREAD the head could not be read refuse to arm
4 EMPTY the head carries NO check run at all refuse to arm

And the empty set is not a legitimate state for this job. arm has needs: [validate, lint], so the validate job — the required context — has COMPLETED by the time this step reads the head, and it left a check run on that head. The lint job is unconditional today and left one too. Only this job's own run is excluded, so the non-self set is never empty by construction. A total of 0 therefore means the head's check runs are not visible here — a wrong head SHA, an API anomaly, or a lookup that does not cover them — and NOT that the head is green. Measured over 48 merged PRs in 6 org repos: the non-self set was never empty (minimum 2), with the required validate / validate present 48/48. Unobserved is not unreachable, so the gate refuses.

The coverage can fire. scripts/head-settle_test.sh already extracts the shipped step's run: text and runs THAT against fixtures, and it already restores the pre-#229 shape as its negative control. This change adds a second control, isolated to this one guard: the shipped text with exactly the class-4 block removed. The shipped text must NOT reach the arming line on an empty set (exit 4); the control MUST (exit 0). If the guard is removed again, the control proves the coverage can fail.

Files:

Head 845afd1229cf5fdc27cf8bc89c5dc2a146279f84 — 2 files changed, 144 insertions, 5 deletions.

Change classification

hook/workflow — one step of the reusable validator workflow, plus the coverage that exercises it. The validate job, the review itself, the required validate / validate context, and the auto-merge mutation are untouched.

How tested

  • The gate for this change class: this repo's own gate, .github/workflows/workflow-checks.yml (the workflow-checks job). The change class is hook/workflow; this repo ships no candy or box, no charly binary and no disposable: true deploy, and the changed code runs in the CALLER's repository — so this repo's CI is the only place its own copy of the step executes, and the owner-script test is the only place its logic can be exercised.
  • Eval beds run — the exact beds and the per-bed result: N/A — this repo has no charly check run <bed> beds: it is the org's GitHub-configuration repo (workflows, shell and Python owner scripts), not a deployable. There is no disposable: true target in it, so no bed applies to this change class; the gate above is the class's gate.
  • Fresh rebuild: N/A — no binary is involved. This repo contains no Go source and ships no charly binary, so charly version does not apply to it.
  • Did the changed code path execute live? Partly, and the limit is stated. The changed step executes live in this PR's own arm job — this repo is the org gate's own first consumer. The PRE-change behaviour was measured, by running the pre-pr-validator: a valid but EMPTY check set still reaches 'all 0 check run(s) passed' and arms auto-merge on a head the gate observed nothing on #248 step text itself against a mocked GitHub API and a mocked clock (block 2): an empty check set reached the arming line with exit 0. The NEW branch (EMPTY = 4) is exercised against the SHIPPED step text with the same mocked API and clock, because a live head with ZERO observable check runs cannot be staged on demand. That is why the tier below is analysed on a live system and not fully tested and validated.
  • Concurrency (shared-state changes only): N/A — the change touches no shared state; the settle step reads one head's check runs and holds no lock.

Block 1 — the change-class gate, run on the committed tree (git status --porcelain empty; the commands are the ones .github/workflows/workflow-checks.yml runs, minus the two uses: runtime steps, which need GitHub's own runners and run in this PR's CI):

$ git status --porcelain
(empty — the tree is the committed one)

$ git log -1 --format='%H %s'
845afd1229cf5fdc27cf8bc89c5dc2a146279f84 fix(ci): an EMPTY check set must never arm auto-merge (#248)

$ bash scripts/org-ruleset_test.sh
  org-ruleset_test: all assertions passed (apply/verify/abort)
  [exit 0]

$ bash scripts/retire-per-repo-dispatchers_test.sh
  retire-per-repo-dispatchers_test: all assertions passed
  [exit 0]

$ bash scripts/retire-per-repo-candy-gates_test.sh
  retire-per-repo-candy-gates_test: all assertions passed
  [exit 0]

$ bash scripts/strip-config-version_test.sh
  strip-config-version_test: all assertions passed
  [exit 0]

$ bash scripts/head-settle_test.sh
  ok  (A) GREEN            -> exit 0  (all 3 check run(s) on this head passed — safe to arm auto-merge)
  ok  (B) NOT-PASSED       -> exit 1  (::error title=A check on this head did not pass::go -> failure)
  ok  (C) IN-FLIGHT        -> exit 2  (::error title=Still in flight — this head could not be confirmed green::still in flight after 3900s — pin-currency)
  ok  (D) UNREAD           -> exit 3  (::error title=The head could not be read::could not read the check runs for deadbeef — refusing to arm auto-merge on an unread head)
  ok  (E1) derived budget  -> exit 0  (the slow sibling settled inside it: all 2 check run(s) on this head passed — safe to arm auto-merge)
  ok  (E2) pre-#229 budget -> exit 2  (the SAME head, the pre-#229 budget: ::error title=Still in flight — this head could not be confirmed green::still in flight after 3600s — pin-currency)
  ok  (E3) the pre-#229 shape -> exit 1  (the SAME head, the SAME budget: reported as a FAILURE, with the old whole-validator remedy)
  ok  (F) invariants      -> backstop 4200s == 70m timeout, derived budget 3900s (backstop - 300s margin) > 1073s (slowest measured sibling)
  ok  (G1) the pre-#229 shape -> exit 0  (an unread head reached 'safe to arm auto-merge' — the hole)
  ok  (G2) the remedy       -> the pre-#229 shape names a whole-validator re-run; the shipped one names this job only
  ok  (G3) the shipped text -> exit 3 (an unread head NEVER reaches the arming line)
  ok  (H1) EMPTY           -> exit 4  (::error title=The head carries no check run to judge::the API answered for deadbeef with ZERO check runs (this gate excluded), so there is nothing to judge. Refusing to arm auto-merge on an unjudgeable head.)
  ok  (H2) the pre-#248 shape -> exit 0  (an EMPTY set reached 'safe to arm auto-merge' — the hole)
  ok  (H3) the pre-#229 shape -> exit 0  (the same hole, one shape older)
  ok  (H4) the class table  -> 4 EMPTY is named in the shipped text, and absent from the pre-#248 control
  head-settle_test: all assertions passed
  [exit 0]

$ bash scripts/reconcile-tag-on-merge-callers_test.sh
  
  reconcile-tag-on-merge-callers_test: 17 passed, 0 failed
  [exit 0]

$ python3 scripts/check-action-runtimes.py
  check-action-runtimes: OK (14 workflow files; 5 audited pins; node24)
  [exit 0]

$ python3 scripts/check-action-runtimes.py --self-test
  check-action-runtimes self-test: OK (Node 20 pins fail)
  [exit 0]

$ bash scripts/bootstrap-repo-main_test.sh
  bootstrap-repo-main_test: all assertions passed
  [exit 0]

$ bash scripts/governance-reconcile.sh --self-test
  governance-reconcile --self-test: all assertions passed
  [exit 0]

$ bash scripts/governance-reconcile.sh
  governance-reconcile: root=/home/atrawog/Sync/Atrapub/a/claude/opencharly/.worktrees/gh229/dotgithub manifest=/home/atrawog/Sync/Atrapub/a/claude/opencharly/.worktrees/gh229/dotgithub/scripts/governance-claims.tsv
  skip  footer_order/umbrella AGENTS.md — AGENTS.md not in this checkout
  skip  footer_order/umbrella AGENTS.md — AGENTS.md not in this checkout
  skip  footer_order/git-workflow skill source — layer-charly-internals/charly.yml not in this checkout
  skip  footer_order/git-workflow skill source — layer-charly-internals/charly.yml not in this checkout
  skip  footer_order/marketplace projection — marketplace/internals/skills/git-workflow/SKILL.md not in this checkout
  skip  footer_order/marketplace projection — marketplace/internals/skills/git-workflow/SKILL.md not in this checkout
  skip  takeover_window/umbrella AGENTS.md — AGENTS.md not in this checkout
  skip  takeover_window/umbrella AGENTS.md — AGENTS.md not in this checkout
  skip  takeover_window/git-workflow skill source — layer-charly-internals/charly.yml not in this checkout
  skip  takeover_window/git-workflow skill source — layer-charly-internals/charly.yml not in this checkout
  skip  takeover_window/marketplace projection — marketplace/internals/skills/git-workflow/references/multi-repo-coordination.md not in this checkout
  skip  takeover_window/marketplace projection — marketplace/internals/skills/git-workflow/references/multi-repo-coordination.md not in this checkout
  skip  comment_first_takeover/umbrella AGENTS.md — AGENTS.md not in this checkout
  skip  comment_first_takeover/git-workflow skill source — layer-charly-internals/charly.yml not in this checkout
  skip  comment_first_takeover/marketplace projection — marketplace/internals/skills/git-workflow/references/multi-repo-coordination.md not in this checkout
  skip  successor_protocol/umbrella AGENTS.md — AGENTS.md not in this checkout
  skip  successor_protocol/git-workflow skill source — layer-charly-internals/charly.yml not in this checkout
  skip  successor_protocol/marketplace projection — marketplace/internals/skills/git-workflow/references/multi-repo-coordination.md not in this checkout
  skip  signoff_account_gate/umbrella AGENTS.md — AGENTS.md not in this checkout
  skip  signoff_account_gate/umbrella AGENTS.md — AGENTS.md not in this checkout
  skip  signoff_account_gate/git-workflow skill source — layer-charly-internals/charly.yml not in this checkout
  skip  signoff_account_gate/git-workflow skill source — layer-charly-internals/charly.yml not in this checkout
  skip  signoff_account_gate/marketplace projection — marketplace/internals/skills/git-workflow/references/multi-repo-coordination.md not in this checkout
  skip  signoff_account_gate/marketplace projection — marketplace/internals/skills/git-workflow/references/multi-repo-coordination.md not in this checkout
  skip  rerun_manual/git-workflow skill source — layer-charly-internals/charly.yml not in this checkout
  skip  rerun_manual/git-workflow skill source — layer-charly-internals/charly.yml not in this checkout
  skip  rerun_manual/git-workflow skill source — layer-charly-internals/charly.yml not in this checkout
  skip  rerun_manual/marketplace projection — marketplace/internals/skills/git-workflow/SKILL.md not in this checkout
  skip  rerun_manual/marketplace projection — marketplace/internals/skills/git-workflow/SKILL.md not in this checkout
  ok    rerun_manual/org-wide required workflow — forbidden phrase absent from .github/workflows/org-wide-pr-validator-required.yml
  ok    rerun_manual/dotgithub README — required phrase present in README.md
  ok    rerun_manual/dotgithub README — forbidden phrase absent from README.md
  ok    rerun_manual/org-wide required workflow — forbidden phrase absent from .github/workflows/org-wide-pr-validator-required.yml
  ok    auto_close_threshold/validator workflow — required phrase present in .github/workflows/pr-validator.yml
  ok    auto_close_infra_excluded/validator workflow — required phrase present in .github/workflows/pr-validator.yml
  ok    auto_close_no_one_shot/validator workflow — forbidden phrase absent from .github/workflows/pr-validator.yml
  ok    auto_close_threshold/validator workflow — required phrase present in .github/workflows/pr-validator.yml
  ok    auto_close_infra_excluded/validator workflow — required phrase present in .github/workflows/pr-validator.yml
  ok    candy_gate_single_pin/org-wide candy validate reusable — required phrase present in .github/workflows/candy-validate.yml
  ok    candy_gate_no_hand_pin/org-wide candy validate reusable — forbidden phrase absent from .github/workflows/candy-validate.yml
  skip  auto_close_threshold/umbrella AGENTS.md — AGENTS.md not in this checkout
  skip  auto_close_threshold/git-workflow skill source — layer-charly-internals/charly.yml not in this checkout
  ok    ci_gate_mechanical/org-wide required workflow — required phrase present in .github/workflows/org-wide-pr-validator-required.yml
  ok    ci_gate_mechanical/validator workflow — required phrase present in .github/workflows/pr-validator.yml
  ok    ci_gate_mechanical/validator workflow — required phrase present in .github/workflows/pr-validator.yml
  ok    ci_gate_mechanical/validator workflow — required phrase present in .github/workflows/pr-validator.yml
  ok    ci_gate_mechanical/validator workflow — required phrase present in .github/workflows/pr-validator.yml
  ok    ci_gate_mechanical/validator workflow — forbidden phrase absent from .github/workflows/pr-validator.yml
  skip  ci_gate_mechanical/validator rulebook — action-review/charly.yml not in this checkout
  skip  ci_gate_mechanical/validator rulebook — action-review/charly.yml not in this checkout
  skip  ci_gate_mechanical/validator rulebook — action-review/charly.yml not in this checkout
  ok    same_repo_gate/org-wide required workflow — required phrase present in .github/workflows/org-wide-pr-validator-required.yml
  ok    same_repo_gate/org-wide required workflow — forbidden phrase absent from .github/workflows/org-wide-pr-validator-required.yml
  skip  fix_or_file/umbrella AGENTS.md — AGENTS.md not in this checkout
  skip  fix_or_file/strict-policy skill source — layer-charly-internals-extra/charly.yml not in this checkout
  skip  fix_or_file/validator rulebook — action-review/charly.yml not in this checkout
  skip  fix_or_file/cutover-policy skill source — layer-charly-internals/charly.yml not in this checkout
  skip  fix_or_file/strict-policy skill source — layer-charly-internals-extra/charly.yml not in this checkout
  skip  fix_or_file/validator rulebook — action-review/charly.yml not in this checkout
  skip  filed_issues_section/umbrella AGENTS.md — AGENTS.md not in this checkout
  ok    filed_issues_section/PR template — required phrase present in .github/PULL_REQUEST_TEMPLATE.md
  ok    filed_issues_section/PR template — required phrase present in .github/PULL_REQUEST_TEMPLATE.md
  skip  read_in_full/umbrella AGENTS.md — AGENTS.md not in this checkout
  skip  read_in_full/git-workflow skill source — layer-charly-internals/charly.yml not in this checkout
  skip  validation_failure_rca/umbrella AGENTS.md — AGENTS.md not in this checkout
  skip  validation_failure_rca/git-workflow skill source — layer-charly-internals/charly.yml not in this checkout
  skip  validation_failure_rca/validator rulebook — action-review/charly.yml not in this checkout
  skip  inform_sessions/umbrella AGENTS.md — AGENTS.md not in this checkout
  skip  inform_sessions/git-workflow skill source — layer-charly-internals/charly.yml not in this checkout
  skip  inform_sessions/git-workflow skill source — layer-charly-internals/charly.yml not in this checkout
  skip  validator_enforces/umbrella AGENTS.md — AGENTS.md not in this checkout
  skip  validator_enforces/validator rulebook — action-review/charly.yml not in this checkout
  skip  validator_enforces/validator rulebook — action-review/charly.yml not in this checkout
  skip  rule_numbering/umbrella AGENTS.md — AGENTS.md not in this checkout
  ok    rule_numbering/PR template — forbidden phrase absent from .github/PULL_REQUEST_TEMPLATE.md
  ok    stall_shapes/detector script — required phrase present in scripts/check-stalled-automerge.sh
  ok    stall_shapes/detector workflow — required phrase present in .github/workflows/stalled-automerge-detector.yml
  ok    stall_shapes/detector script — forbidden phrase absent from scripts/check-stalled-automerge.sh
  ok    stall_shapes/detector workflow — forbidden phrase absent from .github/workflows/stalled-automerge-detector.yml
  ok    unresolvable_pin_infra/validator workflow — required phrase present in .github/workflows/pr-validator.yml
  governance-reconcile: checked=27 skipped=53 fail=0
  [exit 0]

GATE EXIT: 0 (0 = every step passed)

Block 2 — each changed code path executing. The hole, measured on the pre-#248 shipped text (the step text extracted from opencharly/.github@e716f7b, run against the empty-set fixture with a mocked API and a mocked clock), against the shipped text of this commit:

--- the PRE-FIX shipped text (e716f7b)
    exit code: 0
    last line: all 0 check run(s) on this head passed — safe to arm auto-merge
    reached the arming line: YES
--- the SHIPPED text (this commit)
    exit code: 4
    last line: ::error::  gh run rerun --job <that databaseId> --repo opencharly/charly
    reached the arming line: no

and the empty-set assertions inside the gate itself, from block 1:

ok  (H1) EMPTY           -> exit 4  (::error title=The head carries no check run to judge::the API answered for deadbeef with ZERO check runs (this gate excluded), so there is nothing to judge. Refusing to arm auto-merge on an unjudgeable head.)
ok  (H2) the pre-#248 shape -> exit 0  (an EMPTY set reached 'safe to arm auto-merge' — the hole)
ok  (H3) the pre-#229 shape -> exit 0  (the same hole, one shape older)
ok  (H4) the class table  -> 4 EMPTY is named in the shipped text, and absent from the pre-#248 control

Block 3 — the R5 sweep, claim-keyed. The claim is "the gate never arms on a head it observed nothing on". Every surviving occurrence of the old arming phrase, and its classification:

.github/workflows/pr-validator.yml:1432:          #   3 UNREAD     the head could not be read — refusing to arm on an unread head.
.github/workflows/pr-validator.yml:1433:          #   4 EMPTY      the head carries NO check run at all — refusing to arm on an
.github/workflows/pr-validator.yml:1441:          #   "all 0 check run(s) on this head passed — safe to arm auto-merge"
scripts/head-settle_test.sh:18:#   3 UNREAD      the head could not be read — refuse to arm.
scripts/head-settle_test.sh:19:#   4 EMPTY       the head carries NO check run at all — refuse to arm
scripts/head-settle_test.sh:38:#     the step fell through to "all 0 check run(s) on this head passed — safe to arm
scripts/head-settle_test.sh:110:    if lines[i].strip().startswith("#   4 EMPTY"):
scripts/head-settle_test.sh:329:# count reads 0, so before #248 the step fell through to "all 0 check run(s) on this
scripts/head-settle_test.sh:496:# snapshot; every count reads 0; and the step falls through to "all 0 check run(s) on
scripts/head-settle_test.sh:528:grep -q 'all 0 check run(s) on this head passed' "$STATE/h2.out" \
scripts/head-settle_test.sh:544:grep -q '4 EMPTY' "$STATE/step.sh" \
scripts/head-settle_test.sh:546:grep -q '4 EMPTY' "$STATE/step-248.sh" \
scripts/head-settle_test.sh:548:echo "ok  (H4) the class table  -> 4 EMPTY is named in the shipped text, and absent from the pre-#248 control"

Every hit is a comment describing the OLD behaviour, plus the one assertion that PROVES the old text produced it. No live path produces it: the phrase is emitted by the class-4 guard's absence, and the guard is present. The class table itself is enumerated in exactly two files — the workflow and its coverage — and both carry class 4. No legacy path, alias or transitional dual-mode survives: the change is purely additive, and no identifier is removed or renamed.

Block 4 — the live measurement behind "the empty set is not legitimate". 48 merged PRs across 6 org repos, the non-self check set of each merged head:

repo                           pr       sha  all  excl  v/v  lint  others
opencharly/.github            247  e3fceda0    4     3 True  True  validate / lint,validate / validate,workflow-checks
opencharly/.github            242  45e369e0    4     3 True  True  validate / lint,validate / validate,workflow-checks
opencharly/.github            241  ed58d23f    4     3 True  True  validate / lint,validate / validate,workflow-checks
opencharly/.github            239  da046092    4     3 True  True  validate / lint,validate / validate,workflow-checks
opencharly/.github            238  dc834ac3    4     3 True  True  validate / lint,validate / validate,workflow-checks
opencharly/.github            237  4354c824    4     3 True  True  validate / lint,validate / validate,workflow-checks
opencharly/.github            236  a9e25a38    2     2 True False  validate / validate,workflow-checks
opencharly/.github            235  13dc3aa6    2     2 True False  validate / validate,workflow-checks
opencharly/charly            1054  671e4a53    5     4 True  True  go,pin-currency,validate / lint,validate / validate
opencharly/charly            1052  6da62b2b    5     4 True  True  go,pin-currency,validate / lint,validate / validate
opencharly/charly            1050  f6c6ec2a    5     4 True  True  go,pin-currency,validate / lint,validate / validate
opencharly/charly            1047  2a9939fe    7     5 True  True  go,validate / lint,validate / validate
opencharly/charly            1045  3b213c98    4     3 True  True  go,validate / lint,validate / validate
opencharly/charly            1043  3f2eecc4    4     3 True  True  go,validate / lint,validate / validate
opencharly/charly            1042  ac087698    4     3 True  True  go,validate / lint,validate / validate
opencharly/charly            1039  f1df920c    7     5 True  True  go,validate / lint,validate / validate
opencharly/marketplace        575  703a89bd    5     4 True  True  build,deploy,validate / lint,validate / validate
opencharly/marketplace        573  829becd2    5     4 True  True  build,deploy,validate / lint,validate / validate
opencharly/marketplace        572  dd256508    5     4 True  True  build,deploy,validate / lint,validate / validate
opencharly/marketplace        571  fd6e7cbf    5     4 True  True  build,deploy,validate / lint,validate / validate
opencharly/marketplace        569  82fdd108    5     4 True  True  build,deploy,validate / lint,validate / validate
opencharly/marketplace        568  bdb5ff57    5     4 True  True  build,deploy,validate / lint,validate / validate
opencharly/marketplace        567  1c26b7b5    5     4 True  True  build,deploy,validate / lint,validate / validate
opencharly/marketplace        566  bd02ccc4    8     6 True  True  build,deploy,validate / lint,validate / validate
opencharly/sdk                471  cfc00667    8     7 True  True  cross-compile (386),cross-compile (amd64),cross-compile (arm),cross-compile (arm64),go,validate / lint,validate / validate
opencharly/sdk                470  f0ade11f    8     7 True  True  cross-compile (386),cross-compile (amd64),cross-compile (arm),cross-compile (arm64),go,validate / lint,validate / validate
opencharly/sdk                467  2b59aad7    8     7 True  True  cross-compile (386),cross-compile (amd64),cross-compile (arm),cross-compile (arm64),go,validate / lint,validate / validate
opencharly/sdk                464  9878f862    8     7 True  True  cross-compile (386),cross-compile (amd64),cross-compile (arm),cross-compile (arm64),go,validate / lint,validate / validate
opencharly/sdk                463  52c076dc    8     7 True  True  cross-compile (386),cross-compile (amd64),cross-compile (arm),cross-compile (arm64),go,validate / lint,validate / validate
opencharly/sdk                462  0ed8de57    8     7 True  True  cross-compile (386),cross-compile (amd64),cross-compile (arm),cross-compile (arm64),go,validate / lint,validate / validate
opencharly/sdk                457  b8eaa713    8     7 True  True  cross-compile (386),cross-compile (amd64),cross-compile (arm),cross-compile (arm64),go,validate / lint,validate / validate
opencharly/sdk                452  054d14e6    6     6 True False  cross-compile (386),cross-compile (amd64),cross-compile (arm),cross-compile (arm64),go,validate / validate
opencharly/docs               183  ce915087    4     3 True  True  deploy,validate / lint,validate / validate
opencharly/docs               182  ab999e8e    4     3 True  True  deploy,validate / lint,validate / validate
opencharly/docs               180  ceb6bf1b    7     5 True  True  deploy,validate / lint,validate / validate
opencharly/docs               178  dcf4b699    4     3 True  True  deploy,validate / lint,validate / validate
opencharly/docs               176  433503fc    4     3 True  True  deploy,validate / lint,validate / validate
opencharly/docs               174  64a46f49    3     3 True False  deploy,validate / validate
opencharly/docs               172  00017c8a    2     2 True False  deploy,validate / validate
opencharly/docs               170  df52681c    2     2 True False  deploy,validate / validate
opencharly/plugin-task         44  51f89a1a    6     5 True  True  coherence,gate,go,validate / lint,validate / validate
opencharly/plugin-task         43  01484d2e    6     5 True  True  coherence,gate,go,validate / lint,validate / validate
opencharly/plugin-task         41  525c47f0    6     5 True  True  coherence,gate,go,validate / lint,validate / validate
opencharly/plugin-task         38  af406fc6    6     5 True  True  coherence,gate,go,validate / lint,validate / validate
opencharly/plugin-task         36  58dae5d2    4     4 True False  coherence,gate,go,validate / validate
opencharly/plugin-task         30  1dafe72a    4     4 True False  coherence,gate,go,validate / validate
opencharly/plugin-task         28  b2b01048    5     5 True False  coherence,gate,go,validate / validate
opencharly/plugin-task         25  341e5ae9    6     6 True False  coherence,gate,go,validate / validate

merged PRs measured: 48
any with zero non-self check runs: []
any missing 'validate / validate': []
any missing 'validate / lint': [('opencharly/.github', 236), ('opencharly/.github', 235), ('opencharly/sdk', 452), ('opencharly/docs', 174), ('opencharly/docs', 172), ('opencharly/docs', 170), ('opencharly/plugin-task', 36), ('opencharly/plugin-task', 30), ('opencharly/plugin-task', 28), ('opencharly/plugin-task', 25)]
min non-self count: 2

Rulebook compliance

  • R0 — skills loaded before the first tool action: /charly-internals:repo-setup and /charly-internals:git-workflow (this repo's own AGENTS.md names both as its R0 set), and the dispatcher rows for a GitHub workflow change and for a failing gate.
  • R1 — the finding this PR closes was raised by the validator on fix(ci): an unread head must never arm auto-merge, and a SLOW sibling is not a FAILED one #247 and was NOT filed away: it was measured first (48 merged PRs, block 4), and then FIXED here. The one question the finding left open — whether a zero-length set is folded into UNREAD (3) or becomes its own class — was settled by measurement and by the class's own semantics, not by assertion: an unread head is a failed read, an empty set is a successful read that observed nothing, so the two are different facts and the exit code stays the class. No finding was raised on this PR yet; if the validator raises one it will be fixed or rebutted on the thread before any next push. The gate output has zero warnings.
  • R2 — no duplication: the snapshot, the filter and the classification remain one implementation inside the step, and the coverage drives the SHIPPED step text rather than a second copy of the logic.
  • R3 — no workaround: no sleep-and-hope, no blind retry, no suppression, no magic number. The new class refuses; it does not paper over. The existing wait is untouched, and no second budget or interval literal was introduced.
  • R4 — product before prose: the code changed first, and this body describes the code as it now stands. The step's own annotation states the new class and its remedy, so the user-facing text lives with the code.
  • R5 — legacy deleted, with the claim-keyed grep pasted in block 3, and every survivor classified. The change is purely additive: no identifier is removed or renamed, and no transitional or dual-mode path survives.
  • R6 — no unrelated work touched, no destructive git action, no force-push, no bypass, no history rewrite. The branch was cut from a fresh origin/main; one commit; the push is the first. git status --porcelain was empty before the commit and after it.
  • R7 — coverage that fails without the change: the new control removes EXACTLY the class-4 block from the shipped text and the test asserts the result reaches the arming line (blocks (H1) and (H2) in block 1); and the pre-pr-validator: a valid but EMPTY check set still reaches 'all 0 check run(s) passed' and arms auto-merge on a head the gate observed nothing on #248 shipped text, run directly, reached the arming line with exit 0 (block 2). A live head with zero observable check runs cannot be staged on demand, so the discriminating branch is driven against the extracted shipped text with a mocked API and a mocked clock.
  • R8 — no live service is mocked in place of a real one. The API and the clock are fixtures in an offline unit test; no check in this repo is replaced by a fake, and the step itself runs live in this PR's own arm job.
  • R9 — N/A — this repo contains no Go source and ships no charly binary, so there is no binary to rebuild and charly version does not apply to it.
  • R10 — the change-class gate for a workflow change in this repo is .github/workflows/workflow-checks.yml, run on the committed tree (block 1): every offline step returned rc=0 and produced no warning. There is no disposable: true target in this repo, so no bed applies.
  • RDD / ADE / SDD — RDD: the one high-risk assumption is that the empty set is not a legitimate state for this job, and it was proven on live data before the code depended on it (48 merged PRs, block 4) — not read from docs. ADE: N/A — no candy and no plan:. SDD: N/A — no schema and no .cue.
  • Hard cutover — one cutover in one PR: the guard on the set and the coverage for it land together, with no phase, TODO or deferred cleanup left inside the scope. One commit, one squash.
  • Coordination — N/A — the scope is not contended: the issue was filed and CLAIMED before the branch was cut, no other PR or issue touches this step, and the open PR fix(ci): re-pin actions/setup-go to a resolvable REF so validate/validate can start (#206) #207 (a different setup-go pin, same file, different lines) does not overlap this diff. Agent: FIRST and Assisted-by: LAST on the issue and the PR.
  • Kernel/plugin boundary law — N/A — no kernel, no sdk, no plugin, no kind.
  • Disposable-only autonomy — N/A — nothing was destroyed or rebuilt; this repo has no disposable target and no autonomous mutation was performed.
  • Clean architecture + Go gates — N/A — no Go source in this diff. The shell coverage is a single test script, and the gate that runs it (workflow-checks.yml) is unchanged — the test file was already wired into it, so no second copy of the logic and no new gate step.

Attribution tier

analysed on a live system. The change-class gate passed on the final tree with zero warnings, and the change was analysed against live systems — the real GitHub API, the org's real check-run history across 48 merged PRs, and the org gate's own first consumer. It is not fully tested and validated because the discriminating branch (EMPTY = 4) is exercised against the extracted shipped text with a mocked API and a mocked clock: a live head with ZERO observable check runs cannot be staged on demand, and a test that could not fail would be invalid under R7.

Agent: charly
Assisted-by: Claude Code minimax minimax-m3.1-flash:cloud (analysed on a live system)

The head-settle step's snapshot guard is on the BODY (`[[ -n "$body" ]]`). A
well-formed API answer that reports ZERO check runs is not empty, so it
passes that guard; the filter yields {pending:[],bad:[],all:[],total:0}; every
count reads 0; and the step fell through to "all 0 check run(s) on this head
passed - safe to arm auto-merge" with exit 0. The gate was then satisfied by
observing NOTHING - the same fail-open shape #247 removed for the empty body,
one level down.

The guard now also covers the SET. A total of 0 is its own class (exit 4,
EMPTY) and refuses to arm, because `arm` has `needs: [validate, lint]`: the
`validate` job - the required context - has COMPLETED by the time this step
reads the head, and it left a check run on that head. Only this job's own run
is excluded, so the non-self set is never empty by construction. A total of 0
means the head's check runs are not VISIBLE here - not that the head is green.
MEASURED over 48 merged PRs in 6 repos: the non-self set was never empty
(minimum 2), with the required `validate / validate` present 48/48. Unobserved
is not unreachable, so the gate refuses.

scripts/head-settle_test.sh covers it. The shipped text must not reach the
arming line on an empty set; a control built by removing EXACTLY the class-4
block from the shipped text must, so the coverage can fire.

The gate is STRENGTHENED, not loosened: one more refusal, on a state the old
shape could not distinguish from a green head.

Assisted-by: Claude Code minimax minimax-m3.1-flash:cloud (analysed on a live system)
@github-actions

Copy link
Copy Markdown
Contributor

Review — PASS

What the change does, re-derived from the diff

.github/workflows/pr-validator.yml (+34/−0) inserts one self-contained block immediately before the existing nbad= classification in the "Head check-runs" step:

  • a class-table row # 4 EMPTY … appended to the existing 0–3 table;
  • a # CLASS 4 — THE SET MUST NOT BE EMPTY explanatory comment;
  • ntotal="$(jq -r '.total' <<<"$snap")" and if [ "$ntotal" -eq 0 ]; then … exit 4; fi, with five ::error:: annotations naming the class, stating it is not a finding about the change, and giving the cheap "re-run only this job" remedy.

The block reads .total from the same $snap the pre-existing nbad/pending reads use, so the snapshot variable is already defined at that point. It is purely additive: the pre-#229 derivation, the snapshot, the filter, the classes 0–3 handling, the wait/budget and the Enable auto-merge step are untouched. A failed step (exit 4) stops the job before arming, which is the intended fail-closed behaviour.

scripts/head-settle_test.sh (+110/−5) adds:

Correctness / honesty checks

  • Counts: workflow +34, test +110 → 144 insertions, 5 deletions, 2 files — matches the body's claim.
  • Class table enumeration: block 3's grep shows 4 EMPTY only in pr-validator.yml and head-settle_test.sh (the table row and the two assertions that reference it). Consistent with "enumerated in exactly two files".
  • Negative control is not tautological: (H1) fires exit 4 on the shipped text, (H2) fires exit 0 on the same fixture with exactly the class-4 span removed. The removal asserts it found exactly one block, so a future re-shape breaks the control loudly rather than silently passing. The removal span is self-contained (if … fi, no nested if, no comment line that strips to fi), so the reconstructed text stays valid shell.
  • Load-bearing fact: block 4's 48-PR measurement shows the non-self set never empty (min 2) with validate / validate present 48/48; the prose's "lint left one too" is loose (lint is absent in 10 historical PRs per the same table) but the argument rests on validate / validate, which the data supports 48/48. Not a contradiction.
  • Tests fail without the change: (H2)/(H3) prove the pre-fix shapes reach the arming line on the empty set; (H1)/(H4) prove the shipped text refuses and names the class. This is genuine discriminating coverage of the changed branch.
  • Security: no secrets, no new external input; interpolated values (HEAD_SHA, GITHUB_RUN_ID, GITHUB_REPOSITORY) are pre-existing patterns in this step's error paths. No injection surface introduced.
  • Scope: coherent single-purpose change; no dead code, alias, or transitional dual-mode; the guard and its coverage land together.

Comments

There are no comments in the thread, so there is no sign-off to evaluate and nothing to disposition; the change is judged on its own evidence, which is internally consistent and reproduced by the diff.

Verdict: PASS


ollama-cloud/deepseek-v4.1-flash — action-review.

View action run

@github-actions
github-actions Bot enabled auto-merge (squash) October 24, 2026 11:46
@github-actions
github-actions Bot merged commit 3f2ae14 into main Oct 24, 2026
4 checks passed
charly-auto-merge Bot added a commit that referenced this pull request Oct 24, 2026
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.

1 participant