From bdffa4daa5bac3755f74aa1ac3255e3abbde50d0 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:13:32 +0800 Subject: [PATCH 1/7] ci(governance): let agents clear high-risk changes The gate already accepts agent-approved. Make the rule say so: an agent clears high-risk changes itself once the current head has a clean Codex receipt and green contexts, and records the label in the ledger. No change waits on a human label. --- .github/workflows/risk-gate.yml | 4 ++-- AGENTS.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/risk-gate.yml b/.github/workflows/risk-gate.yml index 086bc63f..bb4241a7 100644 --- a/.github/workflows/risk-gate.yml +++ b/.github/workflows/risk-gate.yml @@ -282,7 +282,7 @@ jobs: // pushing onto a bot branch must not inherit the skip. const isDependabot = pr.user?.login === 'dependabot[bot]' && await dependabotHead(number); const actionFiles = isDependabot ? await eligibleActionFiles(pr.base.ref) : null; - await status('risk-gate', 'pending', 'Evaluating changed paths, owner approval and AI review'); + await status('risk-gate', 'pending', 'Evaluating changed paths, clearing label and AI review'); await status('pr-template', 'pending', 'Checking AI, change and verification fields'); await status('ai-review', 'pending', 'Reading the Codex review receipt'); const files = await github.paginate(github.rest.pulls.listFiles, { ...repo, pull_number: number, per_page: 100 }); @@ -338,7 +338,7 @@ jobs: const tierNote = !risky ? 'Low-risk paths: no owner approval needed' : approval === 'owner' ? 'High-risk paths: owner-approved label verified' : approval === 'agent' ? 'High-risk paths: cleared by agent-approved, NOT confirmed by the owner' - : 'High-risk paths: needs-owner-approval; owner must add owner-approved'; + : 'High-risk paths: needs a clearing label; owner-approved or agent-approved'; let gateState; let gateNote; if (!tierOK) { diff --git a/AGENTS.md b/AGENTS.md index 4d530ad5..7db76a60 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,7 +96,7 @@ Organization documentation rules are checked by `python scripts/check_style.py`, - Commit and PR titles use professional English, imperative Conventional Commits (`feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `perf`, `ci`) and at most 72 characters. Sentence case is allowed; commitlint ignores merge commits. - PR descriptions explain the problem, changes, verification and risk. AI-authored PRs identify the tool and the model when available. Labels and approval or risk summaries use professional English. - Use `risk:low` for documentation, tests and ordinary checks; use `risk:high` and `needs-owner-approval` for protected paths. Classification labels describe risk; the path-based `risk-gate` determines whether approval is required. -- AI-only development uses required automated checks, template validation and the `ai-review` context published from the Codex receipt on the current head, with zero required human or code-owner approvals. High-risk paths require a clearing label: `owner-approved` states that the owner confirmed the change; `agent-approved` states that an agent cleared it, and the `risk-gate` description then says `NOT confirmed by the owner`. Agents must never add or remove `owner-approved`. +- AI-only development uses required automated checks, template validation and the `ai-review` context published from the Codex receipt on the current head; no human or code-owner approval is required, and no change waits on a human label. High-risk paths require one clearing label, and either label clears `risk-gate`: an agent may add `agent-approved` itself once the Codex receipt on that head is completed with no unresolved findings and every required context is green, recording the addition in the ledger, and the status description then says `NOT confirmed by the owner`; `owner-approved` stays available when the owner chooses to confirm a change personally, and agents must never add or remove it. A review or label that is not on the current head clears nothing. - Preserve required checks and branch protection; merge only through normal checks. Use `/` PR branches; never push directly to the default branch or force-push a reviewed branch. Commit, push, merge and production actions require authorization; existing authorization remains valid within its scope. ## Scope and authorization From 256af5f95771670800131eaaffea4396fdd56ff8 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:34:22 +0800 Subject: [PATCH 2/7] fix(governance): bind clearing labels to the head and keep the Dependabot contract A label added before a later push cleared the new head. The label event time is now compared with the head commit date, so only a label added after that commit counts. The Dependabot fast path requires the committer to be the bot account, matching the regression test that the previous web-flow predicate broke. --- .github/workflows/risk-gate.yml | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/workflows/risk-gate.yml b/.github/workflows/risk-gate.yml index bb4241a7..e1a9fda1 100644 --- a/.github/workflows/risk-gate.yml +++ b/.github/workflows/risk-gate.yml @@ -194,10 +194,11 @@ jobs: // `author.login` is the account GitHub links from the commit email, // and the author name is free text; a human can set either. A // verified signature only proves some key signed it, so the - // committer must be GitHub itself. One commit outside that shape - // sends the PR down the normal review path. + // committer must also be Dependabot's authenticated bot account. + // Web-interface commits do not qualify. One commit outside that + // shape sends the PR down the normal review path. return commits.every(commit => commit.author?.login === 'dependabot[bot]' - && commit.committer?.login === 'web-flow' + && commit.committer?.login === 'dependabot[bot]' && commit.commit?.verification?.verified === true); } // The dependency policy names the workflows whose action updates may @@ -304,11 +305,19 @@ jobs: let approval = null; if (pr.labels.some(label => APPROVAL_LABELS.includes(label.name))) { const events = await github.paginate(github.rest.issues.listEvents, { ...repo, issue_number: number, per_page: 100 }); + const { data: headCommit } = await github.rest.repos.getCommit({ ...repo, ref: sha }); + const headTime = Date.parse(headCommit.commit?.committer?.date || headCommit.commit?.author?.date || ''); const liveLabel = name => { const latest = events.filter(event => ['labeled', 'unlabeled'].includes(event.event) && event.label?.name === name).sort((a, b) => b.id - a.id)[0]; + // A label only clears the head it was added to. The label list + // still shows it after a later push, so the event time is + // compared with the head commit date; an unreadable date fails + // closed and clears nothing. + const addedAt = Date.parse(latest?.created_at || ''); return latest?.event === 'labeled' && APPROVAL_OWNER !== '' - && latest.actor?.login === APPROVAL_OWNER; + && latest.actor?.login === APPROVAL_OWNER + && Number.isFinite(headTime) && Number.isFinite(addedAt) && addedAt >= headTime; }; // A human confirmation outranks an agent's when both are present. if (liveLabel('owner-approved')) approval = 'owner'; From 3937b5b99b4e6078279cbb4c1f51e2bf6fbfbfa7 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:34:31 +0800 Subject: [PATCH 3/7] docs(governance): exclude risk-gate from the pre-label prerequisite A high-risk PR without a clearing label fails risk-gate by design, so requiring every required context to be green before the label was a circular prerequisite. The other contexts gate the label; risk-gate is checked after it. --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 7db76a60..cf71fd59 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,7 +96,7 @@ Organization documentation rules are checked by `python scripts/check_style.py`, - Commit and PR titles use professional English, imperative Conventional Commits (`feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `perf`, `ci`) and at most 72 characters. Sentence case is allowed; commitlint ignores merge commits. - PR descriptions explain the problem, changes, verification and risk. AI-authored PRs identify the tool and the model when available. Labels and approval or risk summaries use professional English. - Use `risk:low` for documentation, tests and ordinary checks; use `risk:high` and `needs-owner-approval` for protected paths. Classification labels describe risk; the path-based `risk-gate` determines whether approval is required. -- AI-only development uses required automated checks, template validation and the `ai-review` context published from the Codex receipt on the current head; no human or code-owner approval is required, and no change waits on a human label. High-risk paths require one clearing label, and either label clears `risk-gate`: an agent may add `agent-approved` itself once the Codex receipt on that head is completed with no unresolved findings and every required context is green, recording the addition in the ledger, and the status description then says `NOT confirmed by the owner`; `owner-approved` stays available when the owner chooses to confirm a change personally, and agents must never add or remove it. A review or label that is not on the current head clears nothing. +- AI-only development uses required automated checks, template validation and the `ai-review` context published from the Codex receipt on the current head; no human or code-owner approval is required, and no change waits on a human label. High-risk paths require one clearing label, and either label clears `risk-gate`: once the Codex receipt on that head is completed with no unresolved findings and every required context except the label-dependent `risk-gate` is green, an agent may add `agent-approved` itself, record the addition in the ledger, and then wait for `risk-gate` to re-evaluate and pass; the status description then says `NOT confirmed by the owner`. `owner-approved` stays available when the owner chooses to confirm a change personally, and agents must never add or remove it. A review or label that is not on the current head clears nothing. - Preserve required checks and branch protection; merge only through normal checks. Use `/` PR branches; never push directly to the default branch or force-push a reviewed branch. Commit, push, merge and production actions require authorization; existing authorization remains valid within its scope. ## Scope and authorization From 32ac85f16367a1d44e9de9018f320c3d739ce4a2 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 5 Nov 2026 21:53:36 +0800 Subject: [PATCH 4/7] fix(governance): record the label's head and keep the Dependabot identity A label lives on the pull request, so a later push left it looking current. The run for the labelled event now records the head from its payload as a commit status, and later evaluations credit the label only when the current head carries that record. Dependabot's real commits are authored by dependabot[bot] and committed by web-flow from noreply@github.com, the same identity the auto-merge gate checks, so the fast path stays reachable. --- .github/workflows/risk-gate.yml | 53 +++++++++++++++++++++++---------- 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/.github/workflows/risk-gate.yml b/.github/workflows/risk-gate.yml index e1a9fda1..46283387 100644 --- a/.github/workflows/risk-gate.yml +++ b/.github/workflows/risk-gate.yml @@ -193,12 +193,13 @@ jobs: if (!commits.length || commits.length >= 250) return false; // `author.login` is the account GitHub links from the commit email, // and the author name is free text; a human can set either. A - // verified signature only proves some key signed it, so the - // committer must also be Dependabot's authenticated bot account. - // Web-interface commits do not qualify. One commit outside that - // shape sends the PR down the normal review path. + // verified signature only proves some key signed it, so the rest + // of the shape has to match a commit GitHub itself made for the + // bot: committer `web-flow` from `noreply@github.com`. This is the + // same identity `scripts/dependabot_auto_merge_gate.py` accepts. return commits.every(commit => commit.author?.login === 'dependabot[bot]' - && commit.committer?.login === 'dependabot[bot]' + && commit.committer?.login === 'web-flow' + && commit.commit?.committer?.email === 'noreply@github.com' && commit.commit?.verification?.verified === true); } // The dependency policy names the workflows whose action updates may @@ -302,26 +303,46 @@ jobs: // description always names which one applied, so an agent-cleared // high-risk merge is visible instead of passing as a human review. const APPROVAL_LABELS = ['owner-approved', 'agent-approved']; + // A label lives on the pull request, not on a commit, so a push + // after the label leaves it looking current. The run GitHub starts + // for the `labeled` event knows from its payload which head the + // label was added to, and records that head as a commit status. + // Every later evaluation, including a sweep or a Codex-comment + // run, credits the label only when the current head carries the + // record. Commit dates cannot prove which revision was current, + // and a status is keyed by the SHA it names. + const CLEARING_RECORD = 'clearing-label'; let approval = null; if (pr.labels.some(label => APPROVAL_LABELS.includes(label.name))) { const events = await github.paginate(github.rest.issues.listEvents, { ...repo, issue_number: number, per_page: 100 }); - const { data: headCommit } = await github.rest.repos.getCommit({ ...repo, ref: sha }); - const headTime = Date.parse(headCommit.commit?.committer?.date || headCommit.commit?.author?.date || ''); const liveLabel = name => { const latest = events.filter(event => ['labeled', 'unlabeled'].includes(event.event) && event.label?.name === name).sort((a, b) => b.id - a.id)[0]; - // A label only clears the head it was added to. The label list - // still shows it after a later push, so the event time is - // compared with the head commit date; an unreadable date fails - // closed and clears nothing. - const addedAt = Date.parse(latest?.created_at || ''); return latest?.event === 'labeled' && APPROVAL_OWNER !== '' - && latest.actor?.login === APPROVAL_OWNER - && Number.isFinite(headTime) && Number.isFinite(addedAt) && addedAt >= headTime; + && latest.actor?.login === APPROVAL_OWNER ? latest : null; }; // A human confirmation outranks an agent's when both are present. - if (liveLabel('owner-approved')) approval = 'owner'; - else if (liveLabel('agent-approved')) approval = 'agent'; + const live = liveLabel('owner-approved') || liveLabel('agent-approved'); + if (live) { + const name = live.label.name; + const record = `${name} on this head`; + const labeledHead = context.payload.action === 'labeled' + && context.payload.label?.name === name + ? context.payload.pull_request?.head?.sha : null; + if (labeledHead === sha) { + // This run is the label's own event, so the payload's head is + // the authenticated answer: record it for later evaluations. + await status(CLEARING_RECORD, 'success', record); + approval = name === 'owner-approved' ? 'owner' : 'agent'; + } else { + const statuses = await github.paginate(github.rest.repos.listCommitStatusesForRef, + { ...repo, ref: sha, per_page: 100 }); + if (statuses.some(entry => entry.context === CLEARING_RECORD + && entry.state === 'success' && entry.description === record)) { + approval = name === 'owner-approved' ? 'owner' : 'agent'; + } + } + } } const { data: current } = await github.rest.pulls.get({ ...repo, pull_number: number }); if (current.head.sha !== sha || current.body !== pr.body From 6edd1e080c0676473ae3c22a1dd91440ae02e859 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:10:19 +0800 Subject: [PATCH 5/7] fix(governance): scope label records to the PR and record them outside evaluation A commit status can be shared by two pull requests, so the record now names the PR. The record is written by its own job with no concurrency group, because a superseded evaluation would otherwise lose the only copy of which head the label was added to. Each label is validated against the head before owner precedence applies. --- .github/workflows/risk-gate.yml | 88 ++++++++++++++++++++++----------- 1 file changed, 60 insertions(+), 28 deletions(-) diff --git a/.github/workflows/risk-gate.yml b/.github/workflows/risk-gate.yml index 46283387..4d891262 100644 --- a/.github/workflows/risk-gate.yml +++ b/.github/workflows/risk-gate.yml @@ -32,13 +32,52 @@ permissions: actions: write jobs: + # The `labeled` payload is the only authenticated record of which head a + # clearing label was added to. It is written here, in its own job with no + # concurrency group, because an evaluation can be superseded by a later event + # and the evidence must not depend on that run reaching its next step. + record_label: + name: Record the head a clearing label was added to + if: github.event_name == 'pull_request_target' && github.event.action == 'labeled' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + statuses: write + steps: + - name: Publish the label's head as an immutable commit status + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + APPROVAL_OWNER: ${{ vars.RISK_GATE_OWNER }} + with: + script: | + const label = context.payload.label?.name; + if (!['owner-approved', 'agent-approved'].includes(label)) return; + // Only the configured approver's label carries a claim. + if (!process.env.APPROVAL_OWNER || context.payload.sender?.login !== process.env.APPROVAL_OWNER) return; + const sha = context.payload.pull_request?.head?.sha; + // A closed pull request keeps no authority over a commit that + // another pull request may now use. + if (!sha || context.payload.pull_request?.state !== 'open') return; + // The pull request is part of the record: two PRs can share a + // commit, and an approval given on one must not clear the other. + const number = context.payload.pull_request?.number; + if (!number) return; + await github.rest.repos.createCommitStatus({ + ...context.repo, sha, context: 'clearing-label', state: 'success', + description: `${label} for PR ${number} on this head`, + }); + evaluate: name: Evaluate PR governance - if: github.event_name != 'issue_comment' || (github.event.issue.pull_request && (github.event.comment.user.login == 'chatgpt-codex-connector[bot]' || github.event.comment.user.login == 'chatgpt-codex-connector')) + needs: record_label + # A skipped recorder is the normal case for every other event. + if: always() && needs.record_label.result != 'cancelled' + && (github.event_name != 'issue_comment' || (github.event.issue.pull_request && (github.event.comment.user.login == 'chatgpt-codex-connector[bot]' || github.event.comment.user.login == 'chatgpt-codex-connector'))) runs-on: ubuntu-latest timeout-minutes: 5 # Job-level, so a comment from anyone else skips the job without cancelling - # an evaluation already running for this PR. + # an evaluation already running for this PR. Label evidence no longer rides + # on this run, so a superseded evaluation costs nothing. concurrency: group: risk-gate-${{ github.event.pull_request.number || github.event.issue.number || inputs.pr_number }} cancel-in-progress: true @@ -304,13 +343,11 @@ jobs: // high-risk merge is visible instead of passing as a human review. const APPROVAL_LABELS = ['owner-approved', 'agent-approved']; // A label lives on the pull request, not on a commit, so a push - // after the label leaves it looking current. The run GitHub starts - // for the `labeled` event knows from its payload which head the - // label was added to, and records that head as a commit status. - // Every later evaluation, including a sweep or a Codex-comment - // run, credits the label only when the current head carries the - // record. Commit dates cannot prove which revision was current, - // and a status is keyed by the SHA it names. + // after the label leaves it looking current. The `record-label` job + // writes the head from the `labeled` event to a commit status, and + // this evaluation credits the label only when the current head + // carries that record. Commit dates cannot prove which revision was + // current, and a status is keyed by the SHA it names. const CLEARING_RECORD = 'clearing-label'; let approval = null; if (pr.labels.some(label => APPROVAL_LABELS.includes(label.name))) { @@ -321,28 +358,23 @@ jobs: return latest?.event === 'labeled' && APPROVAL_OWNER !== '' && latest.actor?.login === APPROVAL_OWNER ? latest : null; }; - // A human confirmation outranks an agent's when both are present. - const live = liveLabel('owner-approved') || liveLabel('agent-approved'); - if (live) { - const name = live.label.name; - const record = `${name} on this head`; - const labeledHead = context.payload.action === 'labeled' - && context.payload.label?.name === name - ? context.payload.pull_request?.head?.sha : null; - if (labeledHead === sha) { - // This run is the label's own event, so the payload's head is - // the authenticated answer: record it for later evaluations. - await status(CLEARING_RECORD, 'success', record); - approval = name === 'owner-approved' ? 'owner' : 'agent'; - } else { - const statuses = await github.paginate(github.rest.repos.listCommitStatusesForRef, - { ...repo, ref: sha, per_page: 100 }); + // Only a label bound to this head counts, so a stale owner label + // cannot shadow a fresh agent label: each label is checked on its + // own and owner precedence applies after that, never before. + const onHead = {}; + const live = APPROVAL_LABELS.filter(liveLabel); + if (live.length) { + const statuses = await github.paginate(github.rest.repos.listCommitStatusesForRef, + { ...repo, ref: sha, per_page: 100 }); + for (const name of live) { if (statuses.some(entry => entry.context === CLEARING_RECORD - && entry.state === 'success' && entry.description === record)) { - approval = name === 'owner-approved' ? 'owner' : 'agent'; - } + && entry.state === 'success' + && entry.description === `${name} for PR ${number} on this head`)) onHead[name] = true; } } + // A human confirmation outranks an agent's when both are on this head. + if (onHead['owner-approved']) approval = 'owner'; + else if (onHead['agent-approved']) approval = 'agent'; } const { data: current } = await github.rest.pulls.get({ ...repo, pull_number: number }); if (current.head.sha !== sha || current.body !== pr.body From 3497eded463090a97cc2be2a6838528c9fb2b47a Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:32:09 +0800 Subject: [PATCH 6/7] fix(governance): name the label event in the clearing record A label can be removed and added again later. Without the event id, moving the head back to an earlier commit would revive the removed approval, because the stored record and the live event would be two different approvals. The recorder stores the event it handled and the evaluation requires that exact id. --- .github/workflows/risk-gate.yml | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/.github/workflows/risk-gate.yml b/.github/workflows/risk-gate.yml index 4d891262..5a8c7985 100644 --- a/.github/workflows/risk-gate.yml +++ b/.github/workflows/risk-gate.yml @@ -42,6 +42,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 permissions: + issues: read + pull-requests: read statuses: write steps: - name: Publish the label's head as an immutable commit status @@ -62,9 +64,21 @@ jobs: // commit, and an approval given on one must not clear the other. const number = context.payload.pull_request?.number; if (!number) return; + // The event id is part of it too. A label can be revoked and added + // again later; without the id, moving the head back would revive + // the revoked approval, because the record and the live event would + // not be the same approval. + const events = await github.paginate(github.rest.issues.listEvents, { + ...context.repo, issue_number: number, per_page: 100, + }); + const latest = events + .filter(event => event.event === 'labeled' && event.label?.name === label + && event.actor?.login === process.env.APPROVAL_OWNER) + .sort((a, b) => b.id - a.id)[0]; + if (!latest) return; await github.rest.repos.createCommitStatus({ ...context.repo, sha, context: 'clearing-label', state: 'success', - description: `${label} for PR ${number} on this head`, + description: `${label} for PR ${number} at event ${latest.id} on this head`, }); evaluate: @@ -362,14 +376,18 @@ jobs: // cannot shadow a fresh agent label: each label is checked on its // own and owner precedence applies after that, never before. const onHead = {}; - const live = APPROVAL_LABELS.filter(liveLabel); + const live = APPROVAL_LABELS.map(name => [name, liveLabel(name)]).filter(([, event]) => event); if (live.length) { const statuses = await github.paginate(github.rest.repos.listCommitStatusesForRef, { ...repo, ref: sha, per_page: 100 }); - for (const name of live) { + for (const [name, event] of live) { + // The record must name the same event that is live now, so a + // revoked approval is not revived by an older record. if (statuses.some(entry => entry.context === CLEARING_RECORD && entry.state === 'success' - && entry.description === `${name} for PR ${number} on this head`)) onHead[name] = true; + && entry.description === `${name} for PR ${number} at event ${event.id} on this head`)) { + onHead[name] = true; + } } } // A human confirmation outranks an agent's when both are on this head. From e952d0648e595a021c215b0a5e80bf4c94da6be5 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:59:23 +0800 Subject: [PATCH 7/7] fix(governance): bind the clearing record to its own label delivery A run queued for head A can start after the label was removed and added again on head B. Looking up the newest event at execution time then stamped B's addition onto A. The record now matches the addition whose time equals the pull request snapshot that carried the head, and writes nothing when that time matches no addition or more than one. --- .github/workflows/risk-gate.yml | 34 +++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/.github/workflows/risk-gate.yml b/.github/workflows/risk-gate.yml index 5a8c7985..ed836153 100644 --- a/.github/workflows/risk-gate.yml +++ b/.github/workflows/risk-gate.yml @@ -64,21 +64,35 @@ jobs: // commit, and an approval given on one must not clear the other. const number = context.payload.pull_request?.number; if (!number) return; - // The event id is part of it too. A label can be revoked and added - // again later; without the id, moving the head back would revive - // the revoked approval, because the record and the live event would - // not be the same approval. + // The event id is part of the record too. A label can be revoked + // and added again later; without the id, moving the head back would + // revive the revoked approval, because the record and the live + // event would not be the same approval. + // + // The id has to come from this delivery, not from the newest event + // at execution time: a run queued for head A can start after the + // label was revoked and added again on head B, and taking the + // newest event would stamp B's approval onto A. The payload carries + // no event id, but a label change bumps `pull_request.updated_at` + // to the addition's own time, and that snapshot arrived with the + // head it records, so matching on that time names this addition. + const addedAt = context.payload.pull_request?.updated_at; + if (!addedAt) return; const events = await github.paginate(github.rest.issues.listEvents, { ...context.repo, issue_number: number, per_page: 100, }); - const latest = events - .filter(event => event.event === 'labeled' && event.label?.name === label - && event.actor?.login === process.env.APPROVAL_OWNER) - .sort((a, b) => b.id - a.id)[0]; - if (!latest) return; + const matches = events.filter(event => event.event === 'labeled' + && event.label?.name === label + && event.actor?.login === process.env.APPROVAL_OWNER + && event.created_at === addedAt); + // Two additions in the same second cannot be told apart; recording + // either could credit the wrong head, so this delivery records + // nothing and the gate stays closed until a later addition. + if (matches.length !== 1) return; + const record = matches[0]; await github.rest.repos.createCommitStatus({ ...context.repo, sha, context: 'clearing-label', state: 'success', - description: `${label} for PR ${number} at event ${latest.id} on this head`, + description: `${label} for PR ${number} at event ${record.id} on this head`, }); evaluate: