Skip to content

ci: bind the Required Merge Gate to its own event's runs, fail drafts, keep both sides of renames - #345

Open
kiwidream wants to merge 3 commits into
1.x.xfrom
kiwidream/merge-gate-hardening
Open

kiwidream wants to merge 3 commits into
1.x.xfrom
kiwidream/merge-gate-hardening

Conversation

@kiwidream

@kiwidream kiwidream commented Nov 4, 2026 •

Copy link
Copy Markdown
Member

Summary

Fixes #312
Fixes #313
Fixes #314

(GitHub closes issues automatically only on merges into the default branch, so these stay open until 1.x.x reaches main.)

  • Review finding: Required Merge Gate matches check runs by name across events and runs #312, check runs selected by name across runs and events. Each wait in the Required Merge Gate now reads only a workflow run of its own event. The run must be from the workflow that reports the check (ci.yml or core-checks.yml) and match the gate's own run on head SHA, event, head branch and head repository, plus the PR number for pull requests. Check runs are then read from that run's check suite (/check-suites/{id}/check-runs), never from the commit-wide listing. The newest matching run wins, and a re-run stays in its suite, so it is seen. core-checks.yml now declares the gate's pull_request types (opened, synchronize, reopened, ready_for_review, labeled, unlabeled). ci.yml already declared them. Both workflows therefore run for every gate event and are bound the same way, with no fallback. The gate job alone gets actions: read and checks: read; the workflow-level token is now contents: read only.
  • Review finding: skipping the merge gate on drafts leaves a skipped check that counts as passing #313, draft gate skipped. The gate job is no longer skipped for drafts (if: ${{ always() }}). For a draft its step fails first with "Pull request #N is a draft: draft pull requests are not mergeable. Mark it ready for review to run the gate." ready_for_review stays in the triggers, so marking the PR ready starts a fresh gate run.
  • Review finding: changed-file classification hides the source side of renames #314, renames hid the source side. All 31 git diff --name-only changed-file sites in required-merge-gate.yml, ci.yml and core-checks.yml now pass --no-renames. That includes the release-validator detection step in core-checks.yml; the build-smoke job is not touched.

Where the brief's rule had to change, with evidence

The issue and brief proposed binding to runs "created at or after this gate run". Production data shows where that rule would leave the gate waiting forever, and it would then time out red:

  • The runs of one event are not created in order. On wallet: keep the SQLite lock when probing loaded wallet files #297, Full Validation and Core Checks were created at 15:32:34Z and the gate run at 15:32:35Z. The gate therefore counts runs created up to 60 s before its own run (SIBLING_RUN_SKEW_SECONDS). It also counts runs created at least 60 s after the previous gate run on the same head, which is too late to be the previous event's runs. A run created within 60 s after the previous gate run is treated as the previous event's, because the previous event's own runs can also be stamped after its gate run.
  • Core Checks did not run for every gate event. core-checks.yml declared no pull_request types, so it ran only for opened, synchronize and reopened; on subtree: import libbitcoinpqc v0.3.2 signer stack scrub #268 at 17:46:13Z only the gate and Full Validation started. The first round read Core Checks' newest run for those events. An adversarial review showed that fallback had no time bound and ignored the base. With two PRs from one head branch, PR 7's gate passed on PR 8's green docs-only runs. After a retarget, a label or ready event read Core Checks from the old base, so the mainnet publication check for a 1.0.0 target never ran. Round 2 removes the fallback and gives Core Checks the gate's types instead. A contract test reads both workflow files and requires each one to run for every event the gate runs for: every gate pull_request type, the gate's branches for pull_request, push and merge_group, and no paths, paths-ignore or branches-ignore filter.
  • Fork PRs. GitHub ties no workflow run to a pull request whose head is in a fork, the gate's own run included. Requiring the PR number there would deadlock every fork PR. A run must therefore agree with the gate's own run on whether it is tied to the PR, and the head repository and head branch bind it either way.

I re-checked every claim in the three issues against the code and the API. All are confirmed with one exception: #312 says a maintainer had to re-run gate run 41101389289. That run shows run_attempt: 1, so I could not confirm a re-run. The false red itself is confirmed: its log says Full Validation Gate concluded skipped.

Testing

A broken gate blocks every merge, so every test runs the gate's real run: script, lifted out of the YAML, against a stubbed gh. The real changed-file steps run in a scratch git repository with git's default rename detection.

  • ci/checks/test_merge_gate_poller.py (35 tests). FakeGh now routes replies by endpoint and serves a model of GitHub built from the workflow-run fields the API returned for subtree: import libbitcoinpqc v0.3.2 signer stack scrub #268 and wallet: keep the SQLite lock when probing loaded wallet files #297. New tests:
    • The subtree: import libbitcoinpqc v0.3.2 signer stack scrub #268 replay: a draft-era Full Validation Gate concluded skipped, and the new run's gate job does not exist yet. The gate stays pending and passes once this event's check passes.
    • The integration fail-open: the push runs of 1.x.x are green as docs-only, and GitHub ties them to the open PR. The PR's own runs are still running, so the gate stays pending.
    • Runs of another branch, repository or PR (pull request and push) are not read.
    • A fork head binds by repository and branch.
    • A re-run is read from its own suite, even when another suite has a later check run.
    • The creation-time window at both edges, with and without a previous gate run, including a previous event's sibling stamped after its gate run.
    • Both workflows run, unfiltered, for every event the gate runs for, read from the workflow files. For each event kind, an earlier event's runs do not stand in for this one's.
    • The newest run of this event wins. A PR opened with a label gets opened runs and labeled runs seconds apart, all inside the window. The cancelled opened runs are not read, including when the newer runs have the lower run IDs, and within one second the higher run ID wins.
    • A push run inside the window is not read. A push to a branch with an open PR starts push runs and the PR's synchronize runs in the same second, on the same commit and branch, and GitHub ties both to the PR. Only the event filter separates them.
    • A run for the next commit, created 5 s later, is not read even though the fake listing ignores the head_sha filter.
    • Each check is read from the workflow that defines a job with that name.
    • A late-listed run is picked up on the next poll.
    • Unreadable or malformed workflow-run listings fail closed, including when the gate's own run is not listed.
    • An unreadable event context fails before polling.
    • Draft handling: a draft fails before anything is read, an unknown draft state fails, and only pull requests can be drafts. The gate job has no draft condition, ready_for_review is a trigger, and the token scopes are as above.
  • ci/checks/test_changed_files_contract.py (new, 3 tests, run in classify-changes). Every changed-file step of the three workflows runs for pull_request, push, the push of a new branch, merge_group and the diff-tree fallback, on a head that renames src/validation.cpp and ci/release/verify_release.py into doc/reference/. Both sides must be listed, and the real classifier must route the result as source. The release-validator step must report changed=true. A static scan of every workflow requires --no-renames on each git diff, git log or git show that names files.
  • ci/checks/test_classifier_ci_contract.py: its fake gh also serves the workflow-run listing.

Fails on the base, passes with the fix. I replayed the new test files against the 21fee6ee8e workflows:

  • Poller: 37 failures, all in new tests or new subtests. The base's verdicts were Full Validation Gate concluded skipped. for subtree: import libbitcoinpqc v0.3.2 signer stack scrub #268, rc=0 for the push-context fail-open, and rc=0 for a draft. Every pre-existing poller test and subtest still passes there.
  • Changed-file contract: 63 failures. Each rename listed only doc/reference/validation.md and doc/reference/verify_release.md, the release step reported changed=false, and the scan found 31 sites without --no-renames.
  • A mutation check: the earlier previous + 1 floor fails the "runs created just after the previous event" case.

Mutation check of run selection (round 2). Each mutant below was applied to a copy of the workflows, and the poller suite was run against it. Every mutant fails at least one test:

Mutant Fails
max_by([created, .id]) → min_by([created, .id]) test_the_newest_run_of_this_event_wins (all 3 cases)
→ max_by(.id) test_the_newest_run_of_this_event_wins ("labeled created later, with lower IDs")
→ max_by(created) test_the_newest_run_of_this_event_wins ("same second")
drop .event == $event test_another_events_run_inside_the_window_is_not_read
drop .head_sha == $sha test_a_run_for_another_commit_is_not_read
remove core-checks.yml's new types: test_every_event_the_gate_runs_for_starts_the_workflows_it_reads

Acceptance commands, all passing:

  • for t in ci/checks/test_*.py; do python3 "$t"; done on system Python 3.14.4 and on Python 3.12.15: all 8 files OK.
  • actionlint 1.7.12, with shellcheck, on all workflows: clean, as it was on the base.
  • Every workflow file loads with PyYAML.
  • python3 test/lint/lint-files.py: passes after f1071de905 set ci/checks/test_changed_files_contract.py to mode 755 (it has a shebang and was added as 644, which failed CI lint). The other ci/checks/ and .github/ files this PR touches already had matching modes.

Live: this PR changes required-merge-gate.yml, so its own gate runs the new script.

  • Opened as a draft, the gate failed: run 41927559959 reported Pull request #345 is a draft: draft pull requests are not mergeable. Mark it ready for review to run the gate. On the hosted runner, classify-changes ran all four contract suites, including the new changed-file contract, and they passed.

  • Then I marked it ready. The round-1 ready_for_review gate (run 41927824888) was still polling more than 90 minutes later, so the new API reads worked live. A failure to read the runs listing fails closed within about 3 minutes. Its Full Validation run was still queued on the self-hosted runners when round 2 was pushed.

  • Built locally.

  • Ran focused unit or functional tests for the changed area.

  • Ran lint or formatting checks relevant to this change.

  • Not run. Reason:

Target Branch

  • This PR targets main or a maintainer-requested release branch such as 0.1.x. (1.x.x, for v1.0.1.)

Risk / Review Notes

  • Consensus, script, crypto, wallet, P2P, release, CI, or security-sensitive behavior changed.
  • No consensus, script, crypto, wallet, P2P, release, CI, or security-sensitive behavior changed.

Notes:

  • Residual: PRs from one head branch into two bases. GitHub lists every open PR for a head branch on each run. A push to that branch starts both PRs' runs in the same second, so the gate cannot tell them apart, and the newest run wins. Without the Core Checks fallback, another PR's runs can be read only inside that window. The required check is per commit in any case, so the latest Required Merge Gate on the shared head counts for both PRs.
  • Residual: retargeting a PR. A retarget (edited) still keeps the old base's gate result. I did not add edited to the triggers: Full Validation and Core Checks do not run on edited, so a run bound to that event would wait for runs that never start. Every title or body edit would also cancel an in-progress gate through the concurrency group.
  • Residual: events less than 60 s apart. These cannot be told apart by creation time. The newest run wins. A stale run is read only if GitHub has not yet listed this event's run when the gate polls, which is at least about a minute after the event. If that stale run was cancelled by this event's run, the gate fails closed with concluded cancelled. Re-running the gate then reads the newer run.
  • Residual: the floor assumes GitHub creates one event's runs within 60 s of each other. Suppose a run of this event is created more than 60 s before the gate run, and within 60 s after the previous gate run on the head. The gate then never binds it, and it times out. This is theoretical: live data is within ±1 s.
  • Behavior change: label and ready events on a green PR now wait for fresh runs. A labeled, unlabeled or ready_for_review event now waits for a new Full Validation run and, with round 2, a new Core Checks run. Both workflows' cancel-in-progress concurrency also cancels and restarts the runs already in progress for that PR. This costs runner time on every label change.
  • Unchanged: a check that never appears waits the full 5.5 h, then times out red, as before.
  • Residual: the ruleset requires only the name Required Merge Gate. Right after the integration PR opens, the push gate's green check on the same SHA briefly satisfies it. This lasts until the PR's own gate run creates its check: it reports pending at first, then its verdict. The gate cannot close this window; only branch protection could.
  • Residual: the static --no-renames scan is line-based. A git diff command split across backslash continuations is not matched by the scan. The behavioral test still runs every changed-file step that exists today.
  • API cost: each poll lists the runs for the head once more, so 3 calls per poll instead of 2 for the source profile. I found no retry warnings in the 12 most recent gate runs.
  • Overlap: fuzz: re-anchor PQC coverage checks after #270 and run them in CI #342 edits the build-smoke job in core-checks.yml. This PR edits that file's on: block and changed-file steps, and the merge is clean.
  • The draft path costs one classify job per draft event; those jobs already ran for drafts before this change.

Docs / Process Impact

Choose exactly one:

  • I updated public docs because this PR changes user-visible behavior, integration guidance, release/process guidance, or expected validation.
  • No public docs update needed. Reason: CI-internal. ci/README.md and doc/development/public-branch-and-rulesets.md say nothing about drafts or about how the gate selects check runs, so they stay accurate. There is no release-notes fragment because node users see no change.

🤖 Generated with Claude Code

@kiwidream
kiwidream marked this pull request as ready for review November 4, 2026 22:43
@kiwidream

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-05T16:44:37.158407Z 6b14e82 Manual request
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Nice work!

Reviewed commit: 33c37dbbf5

ℹ️ 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".

@kiwidream

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Swish!

Reviewed commit: c405853a63

ℹ️ 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".

@kiwidream

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Delightful!

Reviewed commit: f1071de905

ℹ️ 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".

kiwidream and others added 3 commits November 5, 2026 11:38
The gate read the Core Checks Gate and Full Validation Gate check runs
by name across every check suite on the head commit. On #268 the newest
Full Validation Gate after ready_for_review was the draft-era skipped
one, so the gate failed although nothing had. The same selection lets
the push runs of a branch head, which pass trivially for a docs-only
push, pass an integration pull request from that branch before its own
Full Validation has run.

Each wait now reads a run of the workflow that reports the check: same
head SHA, event, head branch and repository and, for a pull request,
the same pull request. A workflow the event also started has to be
read from a run created at most 60 s before this gate run, because
GitHub stamps the runs of one event up to a second apart in either
order, or at least 60 s after the previous gate run on that head, which
is too late to be the previous event's. Core Checks does not run for
ready_for_review, labeled or unlabeled, so on those events its newest
run for the head is read. The newest matching run wins, and its
check runs are read from its own check suite, so a re-run of it is
seen. Only the gate job reads the API now, with actions: read and
checks: read; the other jobs keep contents: read.

The gate job was skipped for drafts, and a skipped required check
counts as passing until the next gate run reports. Drafts now run the
gate and fail it.

Every changed-file step used git diff with rename detection, which
names only the destination of a rename, so moving a source file into
doc/ routed as docs-only and skipped source validation. All 31 sites
in the three workflows now pass --no-renames.

Refs #312
Refs #313
Refs #314

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The gate read Core Checks' newest run for the head on ready_for_review,
labeled and unlabeled, because core-checks.yml declared no
pull_request types and GitHub starts it only for opened, synchronize
and reopened. That fallback had no time bound and ignored the base: on
a head branch with two pull requests, one could pass on the other's
docs-only runs, and after a retarget a label or ready event read Core
Checks from the old base, so the mainnet publication check of a 1.0.0
target never ran. Core Checks now declares the gate's types, the gate
binds it exactly as it binds Full Validation, and the fallback and
the event action input it needed are gone. A contract test requires
both workflows to run, unfiltered, for every event the gate runs for.

Nothing tested that the newest run of an event wins, or that the jq
event and head SHA filters are needed: the suite passed with min_by,
max_by(.id) or either filter dropped. New tests cover a pull request
opened with a label, whose cancelled opened runs fall inside the
window, a push run started in the same second as the pull request's
synchronize runs, and a run for the next commit. The modelled listing
is now ordered newest first, as GitHub lists runs.

Refs #312

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test/lint/lint-files.py requires files with a shebang line to have
mode 755. ci/checks/test_changed_files_contract.py was added with
mode 644, so the lint job failed.

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

Copy link
Copy Markdown
Member Author

@codex review

@kiwidream
kiwidream force-pushed the kiwidream/merge-gate-hardening branch from f1071de to 6b14e82 Compare November 5, 2026 16:40
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Bravo.

Reviewed commit: 6b14e82355

ℹ️ 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".

This branch has not been deployed

No deployments
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