Fixes #2891 - #2904
Fixes #2891#2904erikdarlingdata wants to merge 5 commits into
Conversation
Nothing in CI looked at commit authorship, so a commit made under an unintended identity landed on dev with every check green. Commit metadata cannot be corrected after merge without a history rewrite that invalidates every clone and every open pull request's merge base, so the control has to be preventive. check-branches now calls .github/scripts/check-commit-identity.sh, which reads the pull request's commit list from the API rather than from a checkout (pull_request_target deliberately never checks out pull request head), paginates it at per_page=100, and fails on any commit whose author OR committer address is off a pinned allowlist. Both fields are checked because a rebase or an amend routinely leaves one right and the other wrong. Three things the allowlist had to get right, each verified against this repo's real history rather than assumed: - The pin is exact addresses, not *@users.noreply.github.com. That pattern admits any GitHub account's noreply address, which is the failure mode being defended against. - GitHub's own committer address is noreply@github.com here, not web-flow@github.com -- the latter appears in this repo's history zero times. Allowlisting only web-flow produces 101 violations on the 250 commits of one real, entirely clean pull request. Both are pinned. - Dependabot is pinned too. It opens grouped pull requests against dev weekly and authors the commits itself, so omitting it would have turned this check into a self-inflicted red every Monday. Errors name the short SHA and which of the two fields was wrong, but deliberately not the address: Actions logs on a public repo are public, and echoing it would publish the identity the check exists to keep out. The check also refuses to report a pass it could not cover. That endpoint caps at 250 commits regardless of paging, and this repo really does have pull requests well above that line, so reading fewer commits than the pull request declares is reported as a coverage failure rather than waved through on the part that was read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ty-guard # Conflicts: # CHANGELOG.md
| # hypothetical here. Reading fewer commits than the pull request declares means the check cannot | ||
| # make its guarantee, and a preventive gate that cannot cover its subject must say so rather than | ||
| # report a pass over the part it read. | ||
| declared=$(gh api -H 'Accept: application/vnd.github+json' "repos/${REPO}/pulls/${PR}" --jq '.commits') |
There was a problem hiding this comment.
Possible false-green risk from GitHub API staleness on pull_request_target + synchronize.
declared comes from GET /pulls/{n} (.commits), a field GitHub computes somewhat asynchronously after a push — it's not guaranteed to reflect a force-push instantaneously. If this job starts running before GitHub's backend has caught up to the new head, two things could both under-report the same stale state: declared reads the old commit count, and the /pulls/{n}/commits list (read earlier in the loop) could likewise still be serving the pre-push set. In that case read_count == declared even though neither reflects the commits actually being merged, and the coverage assertion — the safety net this script relies on to avoid a false pass on a truncated read — wouldn't catch it, since both numbers are stale in the same direction.
Given the design's stated principle ("a false red costs a re-run; a false green is permanent"), it might be worth hedging against this specifically — e.g. compare against github.event.pull_request.head.sha (available in the calling workflow) by checking that the last commit returned by the paginated list actually matches the event's head SHA, rather than trusting the /commits list's completeness relative to a same-family stat that could share its staleness.
This is a plausibility concern from reading GitHub's API behavior, not something reproduced against this script — flagging for awareness rather than asserting it's confirmed to happen.
| page=$((page + 1)) | ||
| done | ||
|
|
||
| pages_read=$page |
There was a problem hiding this comment.
Minor: pages_read can overcount by one in the success message. When the commit count is an exact multiple of PER_PAGE (e.g. exactly 100 commits), the loop reads a full page (n == 100, no break), increments page, then makes one more request that returns n == 0 and breaks — so page is 2 even though only 1 page actually had content. The final message would then say "100 commit(s) over 2 page(s)" when it was really 1 page plus an empty probe. Cosmetic only (doesn't affect the pass/fail verdict), but worth a pages_read=$((page - 1)) style fix if the empty-probe path was taken, since this string ends up in the job's log/summary as the audit trail for "how much was actually read."
|
Reviewed. This is a CI-only change (no T-SQL, no Lite/Darling app code), so the T-SQL style conventions and parity rule don't apply here — scoping the review to the workflow/script itself. Overall: the design is sound — reading commits from the API instead of a checkout (respecting Two things worth a look, both left as inline comments:
No correctness issues found in the allowlist logic, the jq filter, or the workflow's permission scoping ( |
Both from review on this pull request.
The coverage assertion compares the paginated commit list against the count
from GET /pulls/{n}, but those are two endpoints in the same family: a stale
post-push view could leave them AGREEING with each other while both describe
the previous head, and comparing them only to each other cannot see that.
The read is now also required to contain the event's head SHA, which is an
independent anchor and fails closed -- if the API has not caught up, the
anchor is missing, the job goes red, and a re-run clears it. Consistent with
the rest of the script: a false red costs a re-run, a false green is
permanent. Skipped when HEAD_SHA is absent, which is how the script runs
when pointed at an arbitrary pull request by hand.
pages_read counted the trailing empty-page probe, so a commit count that is
an exact multiple of the page size reported one page too many (100 commits
read as "2 page(s)"). It now counts only pages that returned commits. Log
cosmetic, but that line is the audit trail for how much was actually read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ty-guard # Conflicts: # CHANGELOG.md
| # The allowlist. Pinned addresses, NOT a *@users.noreply.github.com pattern: that pattern admits | ||
| # any GitHub account's noreply address, including one belonging to a different identity, which is | ||
| # the exact failure this check exists to stop. |
There was a problem hiding this comment.
Worth stating explicitly somewhere (comment or PR description): git commit.author.email / commit.committer.email are self-declared by whoever runs git commit, not authenticated in any way. Anyone with push access to a branch can run:
git config user.email "2136037+erikdarlingdata@users.noreply.github.com"
git config user.name "erikdarlingdata"
and every commit they make afterward will sail through this check with the exact maintainer identity, GitHub's own noreply@github.com, or Dependabot's. Nothing here (and nothing GitHub provides short of enforced commit signing + vigilant mode) binds these fields to the actual pusher.
That's fine if the goal (per #2891, "a commit made under an unintended identity landed on dev") is catching accidental misconfiguration — a contributor's laptop having the wrong user.email set. But several of the comments in this file lean on stronger language ("the exact failure this check exists to stop", "the precise failure mode #2891 names") that reads like an anti-impersonation control. It isn't one, and a reader relying on it as such would be wrong. Might be worth a one-line caveat so nobody later treats a green check here as proof the commit really came from who it claims.
|
Reviewed this against CONTRIBUTING.md conventions, Lite/Darling parity, correctness, and security. Summary:
No correctness bugs found beyond that; the two issues called out in the PR description's own review-response section (page over-count, stale-view anchoring) are both correctly fixed in this diff. |
Review catch. Author and committer are self-declared git fields that nothing authenticates: anyone who can push can set user.email to an allowlisted value and pass. What the check actually catches is a misconfigured clone -- the wrong user.email left set on a machine -- which is the case #2891 was opened about. Some of the comments here leaned harder than that ("the exact failure this check exists to stop"), which reads like an anti-impersonation control. It is not one, and a green result is not evidence that a commit came from whoever it names; enforced commit signing is the only thing that gets you that. Header now says so, and the allowlist comment is phrased as screening rather than as prevention. Comments only -- no behaviour change. All eight verification cases (two non-conforming, four clean including the 250-commit three-page one and Dependabot, the over-cap coverage failure, and both directions of the head-SHA anchor) re-run identically. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Reviewed. This PR touches only CI infrastructure ( Correctness/logic — walked the pagination loop, the coverage assertion, and the HEAD_SHA anchor check line by line. All hold up:
Security — this is the part that most needed scrutiny given
One low-confidence edge case worth being aware of but not blocking: if a force-push lands mid-pagination on a PR with enough commits to span multiple pages (>100), pages fetched before vs. after the push could theoretically describe two different histories, since each page is an independent live API call rather than a snapshot. In the common single-page case (<100 commits) this can't happen, and even in the multi-page case the HEAD_SHA anchor still catches the resulting inconsistency in most orderings. Given how narrow the window is and how much of this class of staleness the script already defends against, I wouldn't hold up merging on it — just flagging in case it's useful for a future hardening pass. Nice work threading "prevention only, no after-the-fact fix" through the whole design, and being explicit in the header that this checks configuration (a misconfigured clone) rather than identity/authentication. |
|
Closing unmerged. See #2891 for the reasoning — the check catches a misconfigured clone rather than enforcing identity (author/committer are self-declared), and the implementation cost turned out disproportionate to a rule that can simply be followed. The work is preserved here if the trade ever looks different. Thanks for the three rounds of review; both substantive findings (the stale-view false-green and the page-count over-count) were real and were fixed before this decision. |
Adds the commit-identity gate from #2891 to the existing
check-branchesjob.What it does
.github/workflows/check-pr-branch.ymlnow calls.github/scripts/check-commit-identity.sh, which reads the pull request's commit list fromGET /repos/{owner}/{repo}/pulls/{number}/commits(not from a checkout of the pull request) and fails on any commit whosecommit.author.emailorcommit.committer.emailis off a pinned allowlist. Both fields, because a rebase or an amend routinely leaves one right and the other wrong.Errors name the short SHA and which of the two fields was wrong, and deliberately not the address: Actions logs on a public repo are public, so echoing it would publish the identity the check exists to keep out. The remediation block shows how to read the value locally and how to rewrite the branch with
--reset-author, which fixes both fields at once.This check cannot run on its own pull request
pull_request_targetexecutes the workflow file as it exists on the base branch. Thecheck-branchesrun on this pull request isdev's current copy, which has no identity step, so a greencheck-brancheshere is not evidence that any of this works. It takes effect only once merged.So it was verified by running the extracted script directly against real pull request data from the API:
::error::lines (one per field)::error::lines250 commit(s) over 3 page(s)Three corrections to the design in #2891
Each was checked against this repo's actual history rather than assumed.
1. GitHub's committer here is
noreply@github.com, notweb-flow@github.com. The issue says to allowlistweb-flow@github.comor the check fails on every merge commit. That address appears in this repo's history zero times; all 346 GitHub-created merge commits and all 80 squash merges usenoreply@github.com. Allowlisting onlyweb-flowyields 101 violations on the 250 commits of #1250, a pull request that is entirely clean:Both are pinned now:
noreply@github.combecause it is what this repo actually produces,web-flow@github.combecause it costs nothing and GitHub still emits it on some paths.2. Dependabot had to be pinned, or the check would red every Monday. #2891 describes one intended identity. But
.github/dependabot.ymlhas Dependabot opening grouped pull requests againstdevweekly, and it authors those commits itself (committer is GitHub, author is Dependabot). With the issue's allowlist taken literally, #2702 fails on its author field. Its numeric prefix was confirmed to be the real global account id via/users/dependabot[bot].3. The 250-commit cap is not hypothetical in this repo. #2891 calls it "far above anything here". This repo has pull requests at 926, 607, 536, 452, 446, 304 and 304 commits. Rather than pass on the unread tail, the script compares what it read against the count the pull request declares and reports a coverage failure if they differ — a preventive gate that cannot cover its subject should say so rather than report a pass over the part it happened to read. This is a judgement call and the one thing here worth overruling if you would rather a very large pull request warned instead of failed.
Also worth knowing
erikdarlingdata@users.noreply.github.comform of the maintainer address (one commit in history, no numeric prefix) is not allowlisted. Pinning one exact string is the point; noting it in case that is wrong.@users.noreply.github.comwas confirmed to fail, which is the address-shape gap Add a CI check on PR commit author identity #2891 names as the reason not to pattern-match.permissions:block and a base-ref checkout. The checkout pinsref: ${{ github.base_ref }}explicitly rather than relying on the default, so it cannot quietly start resolving to pull request head; no pull request content is fetched or executed.index(...),.refers to the allowlist array, not the commit field, so the first version died withCannot index array with string "email"on every pull request. It failed closed, but it was broken.Not done, deliberately
No existing commit was rewritten. Per #2891 the scope is making future ones impossible; history is out of scope.
Schema version untouched (109), no migration.
Review response (both acted on, neither dismissed)
Anchoring the read to the event head. The coverage assertion compared the paginated commit list against
.commitsfromGET /pulls/{n}— two endpoints in the same family, so a stale post-push view could leave them agreeing with each other while both described the previous head, and comparing them only to each other cannot see that. The read must now also contain the event's head SHA, which is an independent anchor and fails closed: no anchor, no verdict, red, and a re-run clears it. That matches the rest of the script's stance that a false red costs a re-run while a false green is permanent. Skipped whenHEAD_SHAis absent, which is how the script runs when pointed at an arbitrary pull request by hand.Worth noting that this staleness is not theoretical: immediately after pushing the fix,
GET /pulls/2904still reported the previousheadRefOidandmergeable: CONFLICTINGfor a while before catching up. Verified both directions — anchored on the real head SHA it passes and says so; anchored on a SHA absent from the list it fails with the staleness message.Page over-count.
pages_readcounted the trailing empty-page probe, so a commit count that is an exact multiple of the page size reported one page too many. It now counts only pages that returned commits. Verified against the patched loop structure: 100 commits now report 1 page (was 2), 200 report 2 (was 3), 250 still report 3, 3 still report 1.What this check is not
Raised in review and now stated in the script header as well, because it would be easy to read more into a green result than it carries:
commit.author.emailandcommit.committer.emailare self-declared git fields that nothing authenticates. Anyone who can push can setuser.emailto an allowlisted value and pass. What this catches is a misconfigured clone — the wronguser.emailleft set on a machine, which is the case #2891 was opened about — and nothing more. It is not an impersonation control, and a pass here is not evidence that a commit came from whoever it names. Enforced commit signing is the only thing that gets you that.