From bd6f0e881379d7095a43bdb3e707a491b0eb3b33 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 7 Nov 2026 22:14:37 +0800 Subject: [PATCH] ci(governance): adopt the shared PR governance action Replace the inline gate with the pinned v2 composite action, move the repo policy into .github/governance/policy.json, and relay fork review events through the permissionless signal workflow. Status check contexts are unchanged: risk-gate, ai-review, pr-template, clearing-label. --- .github/dot-review/review.cjs | 3 +- .github/dot-review/test-review.cjs | 3 +- .github/governance/policy.json | 24 ++ .github/workflows/pr-review-signal.yml | 25 ++ .github/workflows/risk-gate.yml | 538 ++----------------------- 5 files changed, 97 insertions(+), 496 deletions(-) create mode 100644 .github/governance/policy.json create mode 100644 .github/workflows/pr-review-signal.yml diff --git a/.github/dot-review/review.cjs b/.github/dot-review/review.cjs index c2d19add..049f855d 100644 --- a/.github/dot-review/review.cjs +++ b/.github/dot-review/review.cjs @@ -142,7 +142,8 @@ async function select(github, context) { assert(run.workflow_id === workflow.id && workflow.path === '.github/workflows/risk-gate.yml' && workflow.name === 'PR risk gate' && run.status === 'completed' && run.repository.full_name === `${context.repo.owner}/${context.repo.repo}`, 'untrusted_upstream_workflow'); - if (run.event === 'schedule') return null; + // A sweep and a relayed signal run carry no single pull request. + if (['schedule', 'workflow_run'].includes(run.event)) return null; assert(['pull_request_target','pull_request','workflow_dispatch'].includes(run.event), 'unsupported_upstream_event'); const linked = [...new Set((run.pull_requests || []).map(p => p.number))]; if (linked.length === 1) return linked[0]; diff --git a/.github/dot-review/test-review.cjs b/.github/dot-review/test-review.cjs index 017688b1..859737fe 100644 --- a/.github/dot-review/test-review.cjs +++ b/.github/dot-review/test-review.cjs @@ -70,7 +70,8 @@ test('failed upstream gate can select one PR; wrong workflows and ambiguous asso run.workflow_id=13;await assert.rejects(r.select(github,context));run.workflow_id=12; run.pull_requests=[{number:7},{number:8}];await assert.rejects(r.select(github,context)); run.pull_requests=[];await assert.rejects(r.select(github,context)); - run.event='schedule';assert.equal(await r.select(github,context),null); + for (const event of ['schedule','workflow_run']){run.event=event; + assert.equal(await r.select(github,context),null);} }); for (const kind of ['premerge','posthoc']) for (const uncertain of [false,true]) test(kind + (uncertain ? ' uncertain' : '') + ': offline submit/collect keeps one task and never grants approval', async () => { diff --git a/.github/governance/policy.json b/.github/governance/policy.json new file mode 100644 index 00000000..abb4451c --- /dev/null +++ b/.github/governance/policy.json @@ -0,0 +1,24 @@ +{ + "version": "pr-governance/v3", + "mode": "enforce", + "reviewer": { + "provider": "codex", + "app_id": 1144995, + "user_id": 199175422, + "login": "chatgpt-codex-connector[bot]" + }, + "r0_paths": [ + "README.md", + "CHANGELOG.md", + "docs/prose/" + ], + "r2_paths": [ + "scripts/", + ".github/governance/", + ".github/scripts/", + "policy.schema.json", + "policy.default.json" + ], + "dependabot_template_exemption": true, + "max_evidence_age_hours": 168 +} diff --git a/.github/workflows/pr-review-signal.yml b/.github/workflows/pr-review-signal.yml new file mode 100644 index 00000000..0d9fc3be --- /dev/null +++ b/.github/workflows/pr-review-signal.yml @@ -0,0 +1,25 @@ +name: PR review signal + +# Copy to .github/workflows/pr-review-signal.yml and replace nothing: this +# workflow holds no permissions and runs no repository code. It exists because a +# review event on a fork or Dependabot pull request gets a read-only token, so +# the gate that publishes statuses has to be started by `workflow_run` instead. +# Keep the workflow name; risk-gate.yml refers to it. + +on: + pull_request_review: + types: [submitted, edited, dismissed] + pull_request_review_comment: + types: [created, edited, deleted] + +permissions: + contents: read + +jobs: + signal: + name: Signal review change + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - name: Signal + run: echo "review event recorded" diff --git a/.github/workflows/risk-gate.yml b/.github/workflows/risk-gate.yml index d5bff53e..c558b6a8 100644 --- a/.github/workflows/risk-gate.yml +++ b/.github/workflows/risk-gate.yml @@ -1,18 +1,24 @@ name: PR risk gate +# Copy to .github/workflows/risk-gate.yml and replace QuantStrategyLab/.github with +# the organization's governance repository (for example Pigbibi/governance). +# The job names in this file are the required status check contexts; keep them. + on: pull_request_target: types: [opened, reopened, synchronize, edited, labeled, unlabeled, ready_for_review, closed] - # Codex publishes and refreshes its review receipt as a PR comment, which - # raises no pull_request event. Only that bot can re-trigger the evaluation. issue_comment: types: [created, edited, deleted] - # A merge to the base branch can invalidate a review that already passed and - # raises no PR event, so the gate re-sweeps the open pull requests. + # A review or inline comment on a fork or Dependabot pull request gets a + # read-only token, so those events are relayed through the permissionless + # `PR review signal` workflow and evaluated by this trusted run. + workflow_run: + workflows: ["PR review signal"] + types: [completed] push: branches: [main, master] - # A merge made with `GITHUB_TOKEN` (the Dependabot auto-merge path) raises no - # push event, so a periodic sweep catches the base moves no event reports. + # A merge made with GITHUB_TOKEN raises no push, and nothing raises an event + # for a resolved review thread, so a sweep closes both gaps. schedule: - cron: '17 */6 * * *' workflow_dispatch: @@ -22,8 +28,7 @@ on: required: true type: string -# No checkout, PR code execution, secret access, label writes or merge permission. -# `actions: write` is only used to dispatch a per-PR evaluation after a base push. +# The gate reads metadata only. It never checks out or runs pull request code. permissions: contents: read pull-requests: read @@ -32,10 +37,9 @@ 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. + # The labeled payload is the only authenticated record of which head a + # clearing label was added to. Recording it in its own job keeps the evidence + # independent of an evaluation that a later event may supersede. record_label: name: Record the head a clearing label was added to if: github.event_name == 'pull_request_target' && github.event.action == 'labeled' @@ -46,498 +50,44 @@ jobs: pull-requests: read statuses: write steps: - - name: Publish the label's head as an immutable commit status - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + - name: Record label head + uses: QuantStrategyLab/.github/pr-governance@873eb5afc59a56a394dabe8261e0e2ab5b8f769f env: - APPROVAL_OWNER: ${{ vars.RISK_GATE_OWNER }} + GOVERNANCE_OWNER_ID: ${{ vars.RISK_GATE_OWNER_ID }} + GOVERNANCE_OWNER_LOGIN: ${{ 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; - // 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 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 ${record.id} on this head`, - }); + mode: gate + record-label: 'true' evaluate: name: Evaluate PR governance 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'))) + if: >- + always() && needs.record_label.result != 'cancelled' && + (github.event_name != 'issue_comment' || + (github.event.issue.pull_request && + (github.event.comment.user.type == 'Bot' || + contains(github.event.comment.body || '', 'codex-review')))) 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. Label evidence no longer rides - # on this run, so a superseded evaluation costs nothing. + timeout-minutes: 10 + permissions: + contents: read + pull-requests: read + issues: read + statuses: write + actions: write + # Job-level, so an irrelevant comment skips the job without cancelling an + # evaluation already running. Label evidence no longer rides on this run. concurrency: - group: risk-gate-${{ github.event.pull_request.number || github.event.issue.number || inputs.pr_number }} + group: pr-governance-${{ github.event.pull_request.number || github.event.issue.number || github.event.workflow_run.pull_requests[0].number || inputs.pr_number || github.run_id }} cancel-in-progress: true steps: - - name: Publish risk-gate, AI review and PR template checks on the PR head - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + - name: Evaluate + uses: QuantStrategyLab/.github/pr-governance@873eb5afc59a56a394dabe8261e0e2ab5b8f769f env: - PR_NUMBER: ${{ inputs.pr_number }} - # Required setup: set the RISK_GATE_OWNER repository variable to the - # approver's login (for example, ). The approver is - # repository configuration, not a literal, so the owner can change - # without editing the gate. Unset accepts no label and leaves - # high-risk merges blocked. - APPROVAL_OWNER: ${{ vars.RISK_GATE_OWNER }} + GOVERNANCE_OWNER_ID: ${{ vars.RISK_GATE_OWNER_ID }} + GOVERNANCE_OWNER_LOGIN: ${{ vars.RISK_GATE_OWNER }} with: - script: | - // Executed only by the trusted base-branch workflow. Never load PR code. - const eventName = context.eventName; - // A push or a scheduled sweep has no single PR; both re-evaluate - // them all. Closing a PR can end a shared-head collision, so it - // re-evaluates the remaining open PRs instead of the closed one. - const sweep = ['push', 'schedule'].includes(eventName) - || (eventName === 'pull_request_target' && context.payload.action === 'closed'); - const single = !sweep; - const number = context.payload.pull_request?.number - || context.payload.issue?.number - || Number(process.env.PR_NUMBER); - if (single && (!Number.isSafeInteger(number) || number < 1)) throw new Error('Invalid PR number'); - const repo = context.repo; - const target = `${context.serverUrl}/${repo.owner}/${repo.repo}/actions/runs/${context.runId}`; - // Definitions that decide what passes cannot be relaxed by a PR. - const ALWAYS_RISK_WORKFLOWS = new Set([ - '.github/workflows/risk-gate.yml', - '.github/workflows/offline-submission-regression.yml', - '.github/workflows/repository-hygiene.yml', - '.github/workflows/engineering-governance.yml', - '.github/workflows/dependabot-policy.yml', - ]); - const WORKFLOW_PRIVILEGE = [ - /^\s*permissions\s*:/, - // Any granted scope, quoted or trailed by a comment, including scopes - // GitHub adds later. An unknown key still fails closed here. - /^\s*["']?[a-z][a-z0-9-]*["']?\s*:\s*["']?write["']?\s*(?:#.*)?$/, - /\bsecrets\b/, - /\b(?:GH_TOKEN|GITHUB_TOKEN|github\.token)\b/, - /\bpull_request_target\b/, - /\bworkflow_run\b/, - // Events an unprivileged user can raise against a write-capable workflow. - /\bissue_comment\b/, - /\bpull_request_review_comment\b/, - /\bdiscussion_comment\b/, - /\brepository_dispatch\b/, - ]; - const ACTION_PIN = /^\s*-?\s*uses\s*:/; - const ACTION_TARGET = /^\s*-?\s*uses\s*:\s*["']?([^@"'\s]+)@/; - // The owner login comes from repository configuration so the gate - // does not hard-code an account. An empty value matches nobody, so - // a missing variable blocks high-risk merges instead of widening. - const APPROVAL_OWNER = process.env.APPROVAL_OWNER || ''; - const isWorkflow = path => path.startsWith('.github/workflows/'); - function highRisk(path) { - const p = path.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase(); - if (p.startsWith('.github/governance/') || /(^|\/)codeowners$/.test(p)) return true; - if (/(^|\/)(docs?|tests?|examples?|fixtures)(\/|$)/.test(p)) return false; - if (/(^|\/)\.env(?:[./_-]|$)/.test(p)) return true; - // Runtime configuration decides behaviour the diff does not show. - if (/(^|[\/_.-])(config|configuration|settings)([\/_.-]|$)/.test(p)) return true; - // The dependency policy decides what merges unattended; the PR it - // judges must not be able to relax it. The same applies to any - // other script a trusted workflow runs to publish a required - // check: editing the script disables the check without touching a - // workflow file, and a diff alone cannot show who calls it. - if (/^\.github\/scripts\//.test(path)) return true; - if (/^scripts\/[^/]*(?:check|verify|validate|hygiene|lint)[^/]*\.(?:py|sh|js|cjs|mjs|ts)$/.test(p)) return true; - // Release, deployment, credential and live-trading tooling are named here - // because a diff alone rarely shows what a script changes at run time. - if (/(^|[\/_.-])(ai_service|aiservice|kelly|stop_loss|trad(?:e|es|ing)|execution|executors?|orders?|brokers?|risk|release|deploy(?:ment)?|secrets?|credentials?|iam|service_account|serviceaccount|permission|role_binding|k8s|kubernetes|runtime|strateg(?:y|ies))([\/_.-]|$)/.test(p)) return true; - return /(^|\/)(dockerfile(?:\..*)?|docker-compose[^/]*|compose\.ya?ml|vercel\.json|wrangler\.(?:toml|jsonc?)|firebase\.json)$/.test(p) - || /\.(?:tf|tfvars|pem|key|p12|pfx)$/.test(p); - } - // A repin keeps the action and changes only its ref. Swapping in a - // different action is a different change and takes the normal tier. - function sameAction(entries) { - const targets = sign => entries.filter(entry => entry.sign === sign) - .map(entry => (ACTION_TARGET.exec(entry.text) || [])[1]); - const removed = targets('-'); - const added = targets('+'); - return removed.length > 0 && removed.length === added.length - && removed.every((target, index) => target === added[index]); - } - async function workflowHighRisk(file, isDependabot, actionFiles) { - // A rename must not slip a protected definition past the check. - if (ALWAYS_RISK_WORKFLOWS.has(file.filename) || ALWAYS_RISK_WORKFLOWS.has(file.previous_filename)) return true; - // Renaming a workflow changes the file GitHub discovers, and moving - // it out of the directory disables the checks it published. A - // `.yml.disabled` suffix is still inside the directory but no - // longer a workflow, so any path change takes the protected tier. - if (file.previous_filename && file.previous_filename !== file.filename) return true; - // Without a readable diff the change cannot be proven benign. - if (typeof file.patch !== 'string') return true; - const changed = file.patch.split('\n') - .filter(line => /^[+-]/.test(line) && !/^(?:\+\+\+|---)/.test(line)) - .map(line => ({ sign: line[0], text: line.slice(1) })); - if (changed.some(entry => WORKFLOW_PRIVILEGE.some(pattern => pattern.test(entry.text)))) return true; - // A verified Dependabot update only repins action versions, so the - // change is nothing but one `uses:` line replaced by another. The - // base-branch dependency policy names the workflows whose action - // updates are eligible; anything else takes the normal path. - const pinned = changed.filter(entry => ACTION_PIN.test(entry.text)); - if (isDependabot && actionFiles?.has(file.filename) - && pinned.length === changed.length && sameAction(pinned)) return false; - // Every remaining edit can change what CI verifies: a removed - // assertion, an added `if: false`, a `continue-on-error`. Even a - // `#` line is not provably inert, because inside a `run:` block it - // is shell text that GitHub still expands expressions in before - // Bash sees it, and a comment after a backslash continuation cuts - // the command short. A workflow that publishes a required status - // is exactly where guessing is most expensive, so this tier needs - // a clearing label. - return true; - } - function completeTemplate(body) { - const text = (body || '').replace(//g, ''); - const sections = [...text.matchAll(/^#{1,6}\s+(.+)\r?\n([\s\S]*?)(?=^#{1,6}\s|$(?![\s\S]))/gm)]; - const present = pattern => sections.some(([, heading, content]) => pattern.test(heading) - && content.replace(/```[\s\S]*?```/g, '').replace(/[-*]\s*\[[ x]\]/g, '').trim().length > 3 - && !/^\s*(?:TODO|TBD|N\/A|Describe the change.*|Which AI tool.*|List the checks.*)\s*$/i.test(content)); - return present(/AI|模型|人工智能/i) - && present(/what changed|summary|problem|changes|变更|改动/i) - && present(/how verified|verif|validat|test(?:ing| plan)?|验证|测试/i); - } - // REST and GraphQL disagree on the bot suffix, so accept both forms. - const CODEX_LOGIN = /^chatgpt-codex-connector(?:\[bot\])?$/; - // The receipt is a single JSON object closed just before the HTML - // comment terminator, so the capture must stay greedy. - const REVIEW_RECEIPT = //; - // A receipt names only the head it reviewed, and commit dates are author - // metadata rather than the time a branch moved, so an old review can - // outlive a base push. Git ancestry is the authenticated evidence: the - // reviewed head must contain the current base head, which GitHub - // resolves from the object graph. pr.base.sha lags the live branch, so - // comparing it would miss the same drift. - async function headContainsBase(baseRef, headSha) { - const { data } = await github.rest.repos.getBranch({ ...repo, branch: baseRef }); - const baseHead = data.commit.sha; - const { data: cmp } = await github.rest.repos.compareCommits({ ...repo, base: baseHead, head: headSha }); - return cmp.merge_base_commit?.sha === baseHead; - } - // A Dependabot skip only holds for commits Dependabot itself wrote. - async function dependabotHead(prNumber) { - const commits = await github.paginate(github.rest.pulls.listCommits, { ...repo, pull_number: prNumber, per_page: 100 }); - // The API truncates at 250 entries; an incomplete history cannot - // prove the bot wrote every commit in the head. - 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 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 === 'web-flow' - && commit.commit?.committer?.email === 'noreply@github.com' - && commit.commit?.verification?.verified === true); - } - // The dependency policy names the workflows whose action updates may - // merge unattended. It is read from the base branch, so a PR cannot - // grant itself the exemption by editing the policy it is judged by. - async function eligibleActionFiles(baseRef) { - try { - const { data } = await github.rest.repos.getContent({ - ...repo, path: '.github/scripts/dependabot-policy.json', ref: baseRef, - }); - if (data.type !== 'file' || typeof data.content !== 'string') return null; - const policy = JSON.parse(Buffer.from(data.content, 'base64').toString('utf8')); - return new Set(Array.isArray(policy.action_files) ? policy.action_files : []); - } catch { - // An unreadable or missing policy grants no exemption. - return null; - } - } - // Codex publishes a machine-readable receipt in its rolling summary - // comment. Only a completed review of the current head with no - // unresolved finding counts as a pass; anything else blocks the gate. - async function aiReview(prNumber, headSha, baseContained) { - // A review cannot outlive a base push. Re-requesting a review on the - // same head does not clear this: the merge would still combine that - // head with a base the review never saw, so the branch comes first. - if (!baseContained) { - return { state: 'failure', description: 'Base branch advanced since this head was built; update the branch then comment @codex review' }; - } - const comments = await github.paginate(github.rest.issues.listComments, { ...repo, issue_number: prNumber, per_page: 100 }); - // The newest marker-bearing comment is the one that counts, and it is - // chosen before its contents are validated. Choosing the newest - // *parseable* receipt instead would let a newer malformed or blocking - // receipt fall back to an older clean one, which is approval by - // accident. Comments updated in the same second are ordered by the - // higher id, so a refresher written later in that second still wins. - const stamped = comment => { - const value = Date.parse(comment.updated_at); - return Number.isFinite(value) ? value : 0; - }; - const receipts = comments - .filter(comment => CODEX_LOGIN.test(comment.user?.login || '') - && typeof comment.body === 'string' - && comment.body.includes('codex-review-state:v1')) - .sort((a, b) => stamped(b) - stamped(a) || (b.id || 0) - (a.id || 0)); - if (!receipts.length) return { state: 'pending', description: 'No Codex review yet; comment @codex review to start one' }; - const markers = [...receipts[0].body.matchAll(//g)]; - // Two markers in one comment cannot both be the review that was run. - if (markers.length !== 1) { - return { state: 'error', description: 'Newest Codex review comment has a missing or duplicated receipt' }; - } - let receipt; - try { - receipt = JSON.parse(markers[0][1]); - } catch { - return { state: 'error', description: 'Codex review receipt is not valid JSON' }; - } - const review = receipt?.code_review || {}; - if (review.status !== 'completed') { - return { state: 'pending', description: `Codex review ${review.status || 'not finished'}; comment @codex review to restart it` }; - } - const reviewed = typeof review.commit_sha === 'string' ? review.commit_sha : ''; - if (reviewed !== headSha) { - return { state: 'failure', description: `Codex reviewed ${reviewed.slice(0, 7) || 'another commit'}, not this head; comment @codex review` }; - } - if (review.has_unresolved_findings !== false) { - return { state: 'failure', description: 'Codex review still has unresolved findings on this head' }; - } - return { state: 'success', description: 'Codex reviewed this head with no unresolved findings' }; - } - // Returns true when the gate passes for this PR. Errors are rethrown - // after the statuses are marked, so the caller decides how loud to be. - async function evaluate(number) { - let sha; - const status = (name, state, description) => github.rest.repos.createCommitStatus({ - ...repo, sha, context: name, state, description: String(description).slice(0, 140), target_url: target, - }); - try { - const { data: pr } = await github.rest.pulls.get({ ...repo, pull_number: number }); - // A closed PR keeps no authority over its head: publishing on it - // could overwrite a live PR that now uses the same commit. - if (pr.state !== 'open') return true; - sha = pr.head.sha; - // Commit statuses are keyed by SHA, not by PR. A second open PR on - // the same head would share these contexts, and one verdict could - // overwrite the other, so no status is proof while that is true. - const siblings = await github.paginate(github.rest.pulls.list, { ...repo, state: 'open', per_page: 100 }); - if (siblings.some(item => item.number !== number && item.head?.sha === sha)) { - // The description is the marker the synchronize refresh below - // looks for, so the collision must be named, not folded into - // the generic failure text. - const collision = new Error('Another open pull request shares this head; statuses would collide'); - collision.code = 'SHARED_HEAD'; - throw collision; - } - // Dependabot bodies use their own template; the risk-gate path check still applies below. - // The exemption follows the head commit, not the PR author: a human - // 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, 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 }); - // GitHub caps this API at 3000 files. Incomplete diffs must not pass as low risk. - if (files.length !== pr.changed_files || files.length >= 3000) throw new Error('Incomplete PR file list'); - let risky = false; - for (const file of files) { - const names = [file.filename, file.previous_filename].filter(Boolean); - risky = names.some(isWorkflow) ? await workflowHighRisk(file, isDependabot, actionFiles) : names.some(highRisk); - if (risky) break; - } - // Two labels, two different claims. The owner and their agents share - // one GitHub login, so the event actor cannot tell them apart; the - // label name is the only honest signal left. owner-approved claims a - // human confirmed it, agent-approved claims an agent did. The status - // 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 `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))) { - const events = await github.paginate(github.rest.issues.listEvents, { ...repo, issue_number: number, per_page: 100 }); - 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]; - return latest?.event === 'labeled' && APPROVAL_OWNER !== '' - && latest.actor?.login === APPROVAL_OWNER ? latest : null; - }; - // 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.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, 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} at event ${event.id} 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 - || JSON.stringify(current.labels.map(x => x.name).sort()) !== JSON.stringify(pr.labels.map(x => x.name).sort())) { - throw new Error('PR changed during evaluation; wait for the latest event'); - } - const review = isDependabot - ? { state: 'success', description: 'Automated dependency update: Codex review not required' } - : await aiReview(number, sha, await headContainsBase(pr.base.ref, sha)); - // The opening snapshot cannot see a pull request that appeared on - // this head while the evaluation ran. Publishing a verdict now - // would overwrite the collision error that the newcomer is about - // to write, leaving two open PRs sharing a passing status, so the - // condition is rechecked immediately before any status is written. - const lateSiblings = await github.paginate(github.rest.pulls.list, { ...repo, state: 'open', per_page: 100 }); - if (lateSiblings.some(item => item.number !== number && item.head?.sha === sha)) { - const collision = new Error('Another open pull request shares this head; statuses would collide'); - collision.code = 'SHARED_HEAD'; - throw collision; - } - await status('ai-review', review.state, review.description); - const tierOK = !risky || approval !== null; - 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 a clearing label; owner-approved or agent-approved'; - let gateState; - let gateNote; - if (!tierOK) { - gateState = 'failure'; - gateNote = tierNote; - } else if (review.state === 'success') { - gateState = 'success'; - gateNote = `${tierNote}; AI review passed`; - } else if (review.state === 'pending') { - // An unfinished review is neither a pass nor a failure. - gateState = 'pending'; - gateNote = `${tierNote}; waiting for AI review`; - } else { - gateState = 'failure'; - gateNote = `${tierNote}; ${review.description}`; - } - await status('risk-gate', gateState, gateNote); - const templateOK = isDependabot ? true : completeTemplate(pr.body); - await status('pr-template', templateOK ? 'success' : 'failure', isDependabot - ? 'Automated dependency update: PR template not required' - : templateOK ? 'AI, change and verification fields are filled in' : 'Fill in AI assistance, What changed and How verified sections'); - return gateState !== 'failure' && templateOK; - } catch (error) { - if (sha) { - const detail = error?.code === 'SHARED_HEAD' - ? 'Another open pull request shares this head; statuses would collide' - : 'Evaluation failed; inspect the trusted workflow run'; - await status('risk-gate', 'error', detail); - await status('pr-template', 'error', detail); - await status('ai-review', 'error', detail); - } - throw error; - } - } - // A synchronize moves one pull request off a shared head. The other - // pull request that shared it still carries the collision error and - // would otherwise keep it until its own event or the sweep, so - // re-dispatch only the siblings still showing that verdict. Other - // errors are not collateral: an evaluation that failed for its own - // reasons must not be re-run by an unrelated push. - async function refreshCollidedSiblings() { - if (eventName !== 'pull_request_target' || context.payload.action !== 'synchronize') return; - const open = await github.paginate(github.rest.pulls.list, { ...repo, state: 'open', per_page: 100 }); - for (const item of open) { - if (item.number === number) continue; - const { data: combined } = await github.rest.repos.getCombinedStatusForRef({ ...repo, ref: item.head.sha }); - const gate = (combined.statuses || []).find(entry => entry.context === 'risk-gate'); - if (gate?.state === 'error' && /shares this head/.test(gate.description || '')) { - await github.rest.actions.createWorkflowDispatch({ - ...repo, workflow_id: 'risk-gate.yml', ref: context.payload.repository.default_branch, - inputs: { pr_number: String(item.number) }, - }); - } - } - } - if (single) { - let passed; - try { - passed = await evaluate(number); - } finally { - // A synchronize can release a sibling from a shared head even - // when this evaluation throws, and a stale collision error - // would otherwise keep that sibling blocked until its own - // event or the sweep. Best effort: a refresh failure must not - // replace the evaluation error. - try { - await refreshCollidedSiblings(); - } catch (refreshError) { - core.warning(`Sibling refresh failed: ${refreshError.message}`); - } - } - // A dispatched run only refreshes statuses after the base moved, and - // the verdict already lives on the PR's commit status. Failing the run - // here would paint every merge red for open PRs that are legitimately - // not ready, which buries a real evaluation error. Event-driven runs - // keep failing loudly. - if (!passed && eventName !== 'workflow_dispatch') core.setFailed('Required PR governance checks did not pass'); - } else { - // Merging moves the base without raising a PR event. Dispatch a normal - // per-PR evaluation for each open PR instead of evaluating inline, so - // each one joins that PR's concurrency group and cannot race an - // event-driven run into publishing a stale status. - const open = await github.paginate(github.rest.pulls.list, { ...repo, state: 'open', per_page: 100 }); - for (const item of open) { - await github.rest.actions.createWorkflowDispatch({ - ...repo, workflow_id: 'risk-gate.yml', ref: context.payload.repository.default_branch, - inputs: { pr_number: String(item.number) }, - }); - } - core.info(`Dispatched ${open.length} pull request evaluations after the base branch moved.`); - } + mode: gate + record-label: 'false'