From 9668ce5501a95402916f8fe40927243689b628b4 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:29:20 +0800 Subject: [PATCH 1/9] ci(gate): adopt the unified risk gate and sync the label rule Replace risk-gate.yml with the consolidated version from 12345HotlineBots: Dependabot exemptions require the authenticated account, deleting any run/uses step line is high risk, and base drift is checked through git ancestry. Sync the AGENTS.md label rule: owner-approved means the owner confirmed the change, agent-approved means an agent cleared it, and when both are present owner-approved wins. --- .github/workflows/risk-gate.yml | 308 ++++++++++++++++++++++++++------ AGENTS.md | 2 +- 2 files changed, 251 insertions(+), 59 deletions(-) diff --git a/.github/workflows/risk-gate.yml b/.github/workflows/risk-gate.yml index 63ff8bf..d7be27a 100644 --- a/.github/workflows/risk-gate.yml +++ b/.github/workflows/risk-gate.yml @@ -3,6 +3,14 @@ name: PR risk gate on: pull_request_target: types: [opened, reopened, synchronize, edited, labeled, unlabeled, ready_for_review] + # 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. + push: + branches: [main] workflow_dispatch: inputs: pr_number: @@ -10,59 +18,116 @@ on: required: true type: string -# Only trusted default-branch policy is checked out. No PR code, secrets, label writes or merge permission. +# 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. permissions: contents: read pull-requests: read issues: read statuses: write - -concurrency: - group: risk-gate-${{ github.event.pull_request.number || inputs.pr_number }} - cancel-in-progress: true + actions: write jobs: 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')) 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. + concurrency: + group: risk-gate-${{ github.event.pull_request.number || github.event.issue.number || inputs.pr_number }} + cancel-in-progress: true steps: - - name: Load trusted dependency policy - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - ref: ${{ github.event.repository.default_branch }} - persist-credentials: false - - name: Fetch verified Dependabot metadata - if: github.event.pull_request.user.login == 'dependabot[bot]' && github.event.pull_request.head.repo.full_name == github.repository - id: dependency - uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 - with: - github-token: ${{ github.token }} - - name: Publish risk-gate and PR template checks on the PR head - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + - name: Publish risk-gate, AI review and PR template checks on the PR head + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: PR_NUMBER: ${{ inputs.pr_number }} - DEPENDABOT_DEPENDENCIES: ${{ steps.dependency.outputs.updated-dependencies-json }} with: script: | - // Executed only by the trusted base-branch workflow and policy. Never load PR code. - const number = context.payload.pull_request?.number || Number(process.env.PR_NUMBER); - if (!Number.isSafeInteger(number) || number < 1) throw new Error('Invalid PR number'); + // Executed only by the trusted base-branch workflow. Never load PR code. + const eventName = context.eventName; + // A push to the base branch has no single PR; it re-evaluates them all. + const single = eventName !== 'push'; + 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}`; - let sha; - const status = (name, state, description) => github.rest.repos.createCommitStatus({ - ...repo, sha, context: name, state, description, target_url: target, - }); + // 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 STEP_COMMAND = /^\s*-?\s*(?:run|uses)\s*:/; + // Scheduling and prose carry no behaviour; anything else in a + // credential-bearing workflow is a logic change. Dispatch input + // fields are excluded on purpose: `default` and `required` change + // what a manual run does. + const COSMETIC = /^\s*(?:#|(?:-\s*)?(?:cron|schedule|timeout-minutes|name|retention-days)\s*:)/; + 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/workflows/') || /(^|\/)codeowners$/.test(p)) return true; + if (/(^|\/)codeowners$/.test(p)) return true; if (/(^|\/)(docs?|tests?|examples?|fixtures)(\/|$)/.test(p)) return false; if (/(^|\/)\.env(?:[./_-]|$)/.test(p)) return true; if (/(^|[\/_.-])(trad(?:e|es|ing)|execution|executors?|orders?|brokers?|risk|deploy(?:ment)?|secrets?|credentials?|iam|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)$/.test(p); } + // A workflow that can write, read credentials or touch the host is + // authority-bearing: its logic cannot change without owner approval. + // Persistent self-hosted runners and sudo count as host authority. + async function authorityBearing(path, ref) { + try { + const { data } = await github.rest.repos.getContent({ ...repo, path, ref }); + if (!data || Array.isArray(data) || typeof data.content !== 'string') return true; + const text = Buffer.from(data.content, 'base64').toString('utf8'); + return /\bsecrets\b/.test(text) + || /^\s*["']?[a-z][a-z0-9-]*["']?\s*:\s*["']?write["']?\s*(?:#.*)?$/m.test(text) + || /\bself-hosted\b/.test(text) + || /\bsudo\b/.test(text); + } catch { + return true; + } + } + async function workflowHighRisk(file, ref) { + // 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; + // 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; + // Deleting a step removes verification or behaviour. Rewriting one + // does the same while keeping the counts equal, which is how + // `npm test` becomes `echo "Tests skipped"`. Any removed step + // command is therefore R2; only a pure addition stays cheap. + const removed = changed.filter(entry => entry.sign === '-' && STEP_COMMAND.test(entry.text)).length; + if (removed > 0) return true; + if (!changed.some(entry => !COSMETIC.test(entry.text))) return false; + return authorityBearing(file.filename, ref); + } 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)]; @@ -73,48 +138,175 @@ jobs: && present(/what changed|summary|problem|changes|变更|改动/i) && present(/how verified|verif|validat|test(?:ing| plan)?|验证|测试/i); } - try { - const { data: pr } = await github.rest.pulls.get({ ...repo, pull_number: number }); + // 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 a head Dependabot itself wrote. + async function dependabotHead(ref) { + const { data } = await github.rest.repos.getCommit({ ...repo, ref }); + // The author name is free text: anyone can commit as "dependabot[bot]". + // Only the linked account is authenticated, so the name is not a signal. + // An unlinked commit falls through to the normal review path. + return data.author?.login === 'dependabot[bot]'; + } + // 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 }); + const receipts = comments + .filter(comment => CODEX_LOGIN.test(comment.user?.login || '')) + .map(comment => ({ comment, match: REVIEW_RECEIPT.exec(comment.body || '') })) + .filter(entry => entry.match) + .sort((a, b) => new Date(b.comment.updated_at) - new Date(a.comment.updated_at)); + if (!receipts.length) return { state: 'pending', description: 'No Codex review yet; comment @codex review to start one' }; + let receipt; + try { + receipt = JSON.parse(receipts[0].match[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 }); sha = pr.head.sha; - await status('risk-gate', 'pending', 'Evaluating changed paths and owner approval'); + // 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(pr.head.sha); + await status('risk-gate', 'pending', 'Evaluating changed paths, owner approval 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'); - const policy = require('./.github/scripts/dependabot-policy.cjs'); - const config = require('./.github/scripts/dependabot-policy.json'); - let dependencies; - try { dependencies = JSON.parse(process.env.DEPENDABOT_DEPENDENCIES || 'null'); } catch { dependencies = null; } - const commits = pr.user.login === 'dependabot[bot]' - ? await github.paginate(github.rest.pulls.listCommits, { ...repo, pull_number: number, per_page: 100 }) : []; - const repository = `${repo.owner}/${repo.repo}`; - const verifiedDependabot = policy.trustedPR(config, repository, pr, sha, commits); - const safeDependency = policy.eligible({config, repo: repository, pr, eventHead: sha, dependencies, files, commits}); - const risky = !safeDependency && files.some(file => [file.filename, file.previous_filename].filter(Boolean).some(highRisk)); - let approved = false; - if (pr.labels.some(label => label.name === 'owner-approved')) { + let risky = false; + for (const file of files) { + const names = [file.filename, file.previous_filename].filter(Boolean); + risky = names.some(isWorkflow) ? await workflowHighRisk(file, sha) : 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']; + 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 latest = events.filter(event => ['labeled', 'unlabeled'].includes(event.event) - && event.label?.name === 'owner-approved').sort((a, b) => b.id - a.id)[0]; - // A shared GitHub identity cannot distinguish the owner from their AI agents. - approved = latest?.event === 'labeled' && latest.actor?.login === 'Pigbibi'; + 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' && latest.actor?.login === 'Pigbibi'; + }; + // 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 { 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 allowed = !risky || approved; - await status('risk-gate', allowed ? 'success' : 'failure', safeDependency ? 'Verified dependency policy: CI must pass before merge' : !risky ? 'Low-risk paths: no owner approval needed' - : approved ? 'High-risk paths: owner-approved label verified' : 'High-risk paths: needs-owner-approval; owner must add owner-approved'); - const templateOK = verifiedDependabot || completeTemplate(pr.body); - await status('pr-template', templateOK ? 'success' : 'failure', templateOK - ? (verifiedDependabot ? 'Verified Dependabot-generated PR description' : 'AI, change and verification fields are filled in') : 'Fill in AI assistance, What changed and How verified sections'); - if (!allowed || !templateOK) core.setFailed('Required PR governance checks did not pass'); - } catch (error) { - if (sha) { - await status('risk-gate', 'error', 'Evaluation failed; inspect the trusted workflow run'); - await status('pr-template', 'error', 'Evaluation failed; inspect the trusted workflow run'); + const review = isDependabot + ? { state: 'success', description: 'Automated dependency update: Codex review not required' } + : await aiReview(number, sha, await headContainsBase(pr.base.ref, sha)); + 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-owner-approval; owner must add owner-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) { + await status('risk-gate', 'error', 'Evaluation failed; inspect the trusted workflow run'); + await status('pr-template', 'error', 'Evaluation failed; inspect the trusted workflow run'); + await status('ai-review', 'error', 'Evaluation failed; inspect the trusted workflow run'); + } + throw error; + } + } + if (single) { + const passed = await evaluate(number); + // 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: 'main', inputs: { pr_number: String(item.number) }, + }); } - throw error; + core.info(`Dispatched ${open.length} pull request evaluations after the base branch moved.`); } diff --git a/AGENTS.md b/AGENTS.md index c4ab253..581bd05 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,7 @@ python -m unittest discover -s tests - 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 independent AI review when available, with zero required human or code-owner approvals. High-risk paths require the owner's `owner-approved` label; agents must never add or remove it. +- AI-only development uses required automated checks, template validation and independent AI review when available, with zero required human or code-owner approvals. High-risk paths need an approval label: `owner-approved` states the owner confirmed it, while `agent-approved` states an agent cleared the change itself. Agents must never add or remove `owner-approved`; `risk-gate` names which label applied in its status. When both are present, `owner-approved` wins. - 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. ## AI 维护与交付 From 723d21cf274b31d5ad61e67a858bffe09745376f Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:00:33 +0800 Subject: [PATCH 2/9] fix(gate): tighten workflow risk rules and Dependabot verification Every non-prose workflow edit now needs a clearing label; a multiline run rewrite and an added `if: false` no longer pass as ordinary. Dependabot exemptions require every commit to be authored by the bot, committed by web-flow and signed, and a repin must keep the same action. A periodic sweep covers merges made with GITHUB_TOKEN and dispatches to the default branch. --- .github/workflows/risk-gate.yml | 85 +++++++++++++++++++-------------- 1 file changed, 48 insertions(+), 37 deletions(-) diff --git a/.github/workflows/risk-gate.yml b/.github/workflows/risk-gate.yml index d7be27a..84bbecf 100644 --- a/.github/workflows/risk-gate.yml +++ b/.github/workflows/risk-gate.yml @@ -10,7 +10,11 @@ on: # 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. push: - branches: [main] + 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. + schedule: + - cron: '17 */6 * * *' workflow_dispatch: inputs: pr_number: @@ -47,8 +51,8 @@ jobs: script: | // Executed only by the trusted base-branch workflow. Never load PR code. const eventName = context.eventName; - // A push to the base branch has no single PR; it re-evaluates them all. - const single = eventName !== 'push'; + // A push or a scheduled sweep has no single PR; both re-evaluate them all. + const single = !['push', 'schedule'].includes(eventName); const number = context.payload.pull_request?.number || context.payload.issue?.number || Number(process.env.PR_NUMBER); @@ -78,7 +82,8 @@ jobs: /\bdiscussion_comment\b/, /\brepository_dispatch\b/, ]; - const STEP_COMMAND = /^\s*-?\s*(?:run|uses)\s*:/; + const ACTION_PIN = /^\s*-?\s*uses\s*:/; + const ACTION_TARGET = /^\s*-?\s*uses\s*:\s*["']?([^@"'\s]+)@/; // Scheduling and prose carry no behaviour; anything else in a // credential-bearing workflow is a logic change. Dispatch input // fields are excluded on purpose: `default` and `required` change @@ -94,23 +99,17 @@ jobs: return /(^|\/)(dockerfile(?:\..*)?|docker-compose[^/]*|compose\.ya?ml|vercel\.json|wrangler\.(?:toml|jsonc?)|firebase\.json)$/.test(p) || /\.(?:tf|tfvars)$/.test(p); } - // A workflow that can write, read credentials or touch the host is - // authority-bearing: its logic cannot change without owner approval. - // Persistent self-hosted runners and sudo count as host authority. - async function authorityBearing(path, ref) { - try { - const { data } = await github.rest.repos.getContent({ ...repo, path, ref }); - if (!data || Array.isArray(data) || typeof data.content !== 'string') return true; - const text = Buffer.from(data.content, 'base64').toString('utf8'); - return /\bsecrets\b/.test(text) - || /^\s*["']?[a-z][a-z0-9-]*["']?\s*:\s*["']?write["']?\s*(?:#.*)?$/m.test(text) - || /\bself-hosted\b/.test(text) - || /\bsudo\b/.test(text); - } catch { - return true; - } + // 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, ref) { + async function workflowHighRisk(file, isDependabot) { // 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; // Without a readable diff the change cannot be proven benign. @@ -119,14 +118,18 @@ jobs: .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; - // Deleting a step removes verification or behaviour. Rewriting one - // does the same while keeping the counts equal, which is how - // `npm test` becomes `echo "Tests skipped"`. Any removed step - // command is therefore R2; only a pure addition stays cheap. - const removed = changed.filter(entry => entry.sign === '-' && STEP_COMMAND.test(entry.text)).length; - if (removed > 0) return true; + // A verified Dependabot update only repins action versions, so the + // change is nothing but one `uses:` line replaced by another. Any + // other edit in a bot PR takes the normal path. + const pinned = changed.filter(entry => ACTION_PIN.test(entry.text)); + if (isDependabot && pinned.length === changed.length && sameAction(pinned)) return false; if (!changed.some(entry => !COSMETIC.test(entry.text))) return false; - return authorityBearing(file.filename, ref); + // Every remaining edit can change what CI verifies: a removed + // assertion, a multiline body, an added `if: false`, a + // `continue-on-error`. Nothing below prose is provably harmless, + // and 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, ''); @@ -155,13 +158,20 @@ jobs: 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 a head Dependabot itself wrote. - async function dependabotHead(ref) { - const { data } = await github.rest.repos.getCommit({ ...repo, ref }); - // The author name is free text: anyone can commit as "dependabot[bot]". - // Only the linked account is authenticated, so the name is not a signal. - // An unlinked commit falls through to the normal review path. - return data.author?.login === 'dependabot[bot]'; + // 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 + // committer must be GitHub itself. 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.commit?.verification?.verified === true); } // Codex publishes a machine-readable receipt in its rolling summary // comment. Only a completed review of the current head with no @@ -212,7 +222,7 @@ jobs: // 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(pr.head.sha); + const isDependabot = pr.user?.login === 'dependabot[bot]' && await dependabotHead(number); await status('risk-gate', 'pending', 'Evaluating changed paths, owner approval and AI review'); await status('pr-template', 'pending', 'Checking AI, change and verification fields'); await status('ai-review', 'pending', 'Reading the Codex review receipt'); @@ -222,7 +232,7 @@ jobs: let risky = false; for (const file of files) { const names = [file.filename, file.previous_filename].filter(Boolean); - risky = names.some(isWorkflow) ? await workflowHighRisk(file, sha) : names.some(highRisk); + risky = names.some(isWorkflow) ? await workflowHighRisk(file, isDependabot) : names.some(highRisk); if (risky) break; } // Two labels, two different claims. The owner and their agents share @@ -305,7 +315,8 @@ jobs: 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: 'main', inputs: { pr_number: String(item.number) }, + ...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.`); From e5d3828dc238148682bae859daf1fb586d2fedaf Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:15:57 +0800 Subject: [PATCH 3/9] fix(gate): tighten workflow risk rules and Dependabot verification The approver login is read from the RISK_GATE_OWNER repository variable; an unset variable accepts no label. Job names, workflows moved out of the directory and runtime config files are now protected. Dependabot exemptions require every commit to be authored by the bot, committed by web-flow and signed, and a repin must keep the same action and be listed in the base-branch policy action_files. A periodic sweep covers merges made with GITHUB_TOKEN and dispatches to the default branch. --- .github/workflows/risk-gate.yml | 58 ++++++++++++++++++++++++++------- 1 file changed, 47 insertions(+), 11 deletions(-) diff --git a/.github/workflows/risk-gate.yml b/.github/workflows/risk-gate.yml index 84bbecf..e9aebb6 100644 --- a/.github/workflows/risk-gate.yml +++ b/.github/workflows/risk-gate.yml @@ -47,6 +47,10 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: PR_NUMBER: ${{ inputs.pr_number }} + # The approver is repository configuration, not a literal in the + # workflow: repos that keep accounts out of source can change the + # owner without editing the gate. Unset means no label is accepted. + APPROVAL_OWNER: ${{ vars.RISK_GATE_OWNER }} with: script: | // Executed only by the trusted base-branch workflow. Never load PR code. @@ -84,17 +88,25 @@ jobs: ]; const ACTION_PIN = /^\s*-?\s*uses\s*:/; const ACTION_TARGET = /^\s*-?\s*uses\s*:\s*["']?([^@"'\s]+)@/; - // Scheduling and prose carry no behaviour; anything else in a - // credential-bearing workflow is a logic change. Dispatch input - // fields are excluded on purpose: `default` and `required` change - // what a manual run does. - const COSMETIC = /^\s*(?:#|(?:-\s*)?(?:cron|schedule|timeout-minutes|name|retention-days)\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 || ''; + // Scheduling, comments and step labels carry no behaviour; anything + // else in a credential-bearing workflow is a logic change. A + // job-level `name:` defines the required check identity, so only the + // top-level workflow name and `- name:` step labels stay cosmetic. + // Dispatch input fields are excluded on purpose: `default` and + // `required` change what a manual run does. + const COSMETIC = /^(?:\s*#|name\s*:|\s+-\s*name\s*:|\s*(?:-\s*)?(?:cron|schedule|timeout-minutes|retention-days)\s*:)/; const isWorkflow = path => path.startsWith('.github/workflows/'); function highRisk(path) { const p = path.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase(); if (/(^|\/)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; if (/(^|[\/_.-])(trad(?:e|es|ing)|execution|executors?|orders?|brokers?|risk|deploy(?:ment)?|secrets?|credentials?|iam|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)$/.test(p); @@ -109,9 +121,13 @@ jobs: return removed.length > 0 && removed.length === added.length && removed.every((target, index) => target === added[index]); } - async function workflowHighRisk(file, isDependabot) { + 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; + // Moving a live workflow out of the directory disables the checks it + // published, whatever else the patch says. + if (file.previous_filename && isWorkflow(file.previous_filename) + && !isWorkflow(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') @@ -119,10 +135,12 @@ jobs: .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. Any - // other edit in a bot PR takes the normal path. + // 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 && pinned.length === changed.length && sameAction(pinned)) return false; + if (isDependabot && actionFiles?.has(file.filename) + && pinned.length === changed.length && sameAction(pinned)) return false; if (!changed.some(entry => !COSMETIC.test(entry.text))) return false; // Every remaining edit can change what CI verifies: a removed // assertion, a multiline body, an added `if: false`, a @@ -173,6 +191,22 @@ jobs: && commit.committer?.login === 'web-flow' && 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. @@ -223,6 +257,7 @@ jobs: // 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, owner approval and AI review'); await status('pr-template', 'pending', 'Checking AI, change and verification fields'); await status('ai-review', 'pending', 'Reading the Codex review receipt'); @@ -232,7 +267,7 @@ jobs: 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) : names.some(highRisk); + 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 @@ -248,7 +283,8 @@ jobs: 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' && latest.actor?.login === 'Pigbibi'; + return latest?.event === 'labeled' && APPROVAL_OWNER !== '' + && latest.actor?.login === APPROVAL_OWNER; }; // A human confirmation outranks an agent's when both are present. if (liveLabel('owner-approved')) approval = 'owner'; From 46cb38316c0f5608b2868fe01b1b6ea49eeede27 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:31:46 +0800 Subject: [PATCH 4/9] fix(gate): tighten workflow risk rules and Dependabot verification The approver login is read from the RISK_GATE_OWNER repository variable; an unset variable accepts no label. Job names, any workflow rename, runtime config files and the dependency policy script are now protected. Dependabot exemptions require every commit to be authored by the bot, committed by web-flow and signed, and a repin must keep the same action and be listed in the base-branch policy action_files. An open PR sharing the same head fails closed. A periodic sweep covers merges made with GITHUB_TOKEN and dispatches to the default branch. --- .github/workflows/risk-gate.yml | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/.github/workflows/risk-gate.yml b/.github/workflows/risk-gate.yml index e9aebb6..1cfe3eb 100644 --- a/.github/workflows/risk-gate.yml +++ b/.github/workflows/risk-gate.yml @@ -107,6 +107,9 @@ jobs: 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. + if (/^\.github\/scripts\/dependabot-policy\.[^/]+$/.test(path)) return true; if (/(^|[\/_.-])(trad(?:e|es|ing)|execution|executors?|orders?|brokers?|risk|deploy(?:ment)?|secrets?|credentials?|iam|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)$/.test(p); @@ -124,10 +127,11 @@ jobs: 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; - // Moving a live workflow out of the directory disables the checks it - // published, whatever else the patch says. - if (file.previous_filename && isWorkflow(file.previous_filename) - && !isWorkflow(file.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') @@ -253,6 +257,13 @@ jobs: try { const { data: pr } = await github.rest.pulls.get({ ...repo, pull_number: number }); 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)) { + throw new Error('Another open pull request shares this head; statuses would collide'); + } // 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. From 5dc4b5134544037047091b8bbaef4c2840bc2208 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 4 Nov 2026 23:55:20 +0800 Subject: [PATCH 5/9] fix(gate): tighten workflow risk rules and Dependabot verification The approver login is read from the RISK_GATE_OWNER repository variable; an unset variable accepts no label. Job names, any workflow rename, runtime config files and the dependency policy script are now protected. Dependabot exemptions require every commit to be authored by the bot, committed by web-flow and signed, and a repin must keep the same action and be listed in the base-branch policy action_files. An open PR sharing the same head fails closed. A periodic sweep covers merges made with GITHUB_TOKEN and dispatches to the default branch. --- .github/workflows/risk-gate.yml | 34 ++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/.github/workflows/risk-gate.yml b/.github/workflows/risk-gate.yml index 1cfe3eb..7062bea 100644 --- a/.github/workflows/risk-gate.yml +++ b/.github/workflows/risk-gate.yml @@ -2,7 +2,7 @@ name: PR risk gate on: pull_request_target: - types: [opened, reopened, synchronize, edited, labeled, unlabeled, ready_for_review] + 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: @@ -47,16 +47,22 @@ jobs: uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 env: PR_NUMBER: ${{ inputs.pr_number }} - # The approver is repository configuration, not a literal in the - # workflow: repos that keep accounts out of source can change the - # owner without editing the gate. Unset means no label is accepted. + # 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 }} 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. - const single = !['push', 'schedule'].includes(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); @@ -92,13 +98,12 @@ jobs: // 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 || ''; - // Scheduling, comments and step labels carry no behaviour; anything - // else in a credential-bearing workflow is a logic change. A - // job-level `name:` defines the required check identity, so only the - // top-level workflow name and `- name:` step labels stay cosmetic. - // Dispatch input fields are excluded on purpose: `default` and - // `required` change what a manual run does. - const COSMETIC = /^(?:\s*#|name\s*:|\s+-\s*name\s*:|\s*(?:-\s*)?(?:cron|schedule|timeout-minutes|retention-days)\s*:)/; + // Only a comment is provably inert. Every key, including `name:`, + // `cron:` and `timeout-minutes`, has behavioural meaning somewhere, + // and a line inside a `run: |` block is shell text that only looks + // like YAML. Treating any of it as prose lets a shell line such as + // `- name: $(...)` through, so the whole file takes one tier. + const COSMETIC = /^\s*#/; const isWorkflow = path => path.startsWith('.github/workflows/'); function highRisk(path) { const p = path.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase(); @@ -256,6 +261,9 @@ jobs: }); 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 From 47b784a7492ea1bb09f6ca679288b78bacf46773 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:14:07 +0800 Subject: [PATCH 6/9] fix(gate): tighten workflow risk rules and Dependabot verification No workflow line is inert: outside a verified Dependabot same-action repin, every edit needs a clearing label, and a `#` line in a run block is shell text whose `${{ }}` expands before Bash sees it. The approver login is read from the RISK_GATE_OWNER repository variable; an unset variable accepts no label. Job names, any workflow rename, runtime config files and the dependency policy script are protected, and a shared head fails closed. A periodic sweep covers merges made with GITHUB_TOKEN. --- .github/workflows/risk-gate.yml | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/.github/workflows/risk-gate.yml b/.github/workflows/risk-gate.yml index 7062bea..cf8505e 100644 --- a/.github/workflows/risk-gate.yml +++ b/.github/workflows/risk-gate.yml @@ -98,12 +98,6 @@ jobs: // 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 || ''; - // Only a comment is provably inert. Every key, including `name:`, - // `cron:` and `timeout-minutes`, has behavioural meaning somewhere, - // and a line inside a `run: |` block is shell text that only looks - // like YAML. Treating any of it as prose lets a shell line such as - // `- name: $(...)` through, so the whole file takes one tier. - const COSMETIC = /^\s*#/; const isWorkflow = path => path.startsWith('.github/workflows/'); function highRisk(path) { const p = path.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase(); @@ -150,12 +144,14 @@ jobs: const pinned = changed.filter(entry => ACTION_PIN.test(entry.text)); if (isDependabot && actionFiles?.has(file.filename) && pinned.length === changed.length && sameAction(pinned)) return false; - if (!changed.some(entry => !COSMETIC.test(entry.text))) return false; // Every remaining edit can change what CI verifies: a removed - // assertion, a multiline body, an added `if: false`, a - // `continue-on-error`. Nothing below prose is provably harmless, - // and a workflow that publishes a required status is exactly where - // guessing is most expensive, so this tier needs a clearing label. + // 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 `${{ }}` into 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) { From 5746c1179b6fff21f69f8b1791c8d19381fb66da Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:37:50 +0800 Subject: [PATCH 7/9] fix(gate): tighten workflow risk rules and Dependabot verification No workflow line is inert: outside a verified Dependabot same-action repin, every edit needs a clearing label, and a `#` line in a run block is shell text whose `${{ }}` expands before Bash sees it. The approver login is read from the RISK_GATE_OWNER repository variable; an unset variable accepts no label. Job names, any workflow rename, runtime config files and the dependency policy script are protected, and a shared head fails closed with the leftover error refreshed when the head changes or the duplicate closes. A periodic sweep covers merges made with GITHUB_TOKEN. --- .github/workflows/risk-gate.yml | 40 ++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/.github/workflows/risk-gate.yml b/.github/workflows/risk-gate.yml index cf8505e..f1783f1 100644 --- a/.github/workflows/risk-gate.yml +++ b/.github/workflows/risk-gate.yml @@ -147,7 +147,7 @@ jobs: // 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 `${{ }}` into before + // 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 @@ -266,7 +266,12 @@ jobs: // 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)) { - throw new Error('Another open pull request shares this head; statuses would collide'); + // 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 @@ -343,15 +348,40 @@ jobs: return gateState !== 'failure' && templateOK; } catch (error) { if (sha) { - await status('risk-gate', 'error', 'Evaluation failed; inspect the trusted workflow run'); - await status('pr-template', 'error', 'Evaluation failed; inspect the trusted workflow run'); - await status('ai-review', 'error', 'Evaluation failed; inspect the trusted workflow run'); + 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) { const passed = await evaluate(number); + await refreshCollidedSiblings(); // 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 From a54409c12aaaf203d26063f2d018bb5437968267 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:54:13 +0800 Subject: [PATCH 8/9] fix(gate): tighten workflow risk rules and Dependabot verification No workflow line is inert: outside a verified Dependabot same-action repin, every edit needs a clearing label, and a `#` line in a run block is shell text whose `${{ }}` expands before Bash sees it. The approver login is read from the RISK_GATE_OWNER repository variable; an unset variable accepts no label. Job names, any workflow rename, runtime config files and the dependency policy script are protected, and a shared head fails closed with the leftover error refreshed when the head changes or the duplicate closes. A periodic sweep covers merges made with GITHUB_TOKEN. --- .github/workflows/risk-gate.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/risk-gate.yml b/.github/workflows/risk-gate.yml index f1783f1..52d7836 100644 --- a/.github/workflows/risk-gate.yml +++ b/.github/workflows/risk-gate.yml @@ -318,6 +318,17 @@ jobs: 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' From 219dff4c8e727ac15f43173595d1763dc9c95539 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 5 Nov 2026 01:07:08 +0800 Subject: [PATCH 9/9] fix(gate): tighten workflow risk rules and Dependabot verification No workflow line is inert: outside a verified Dependabot same-action repin, every edit needs a clearing label, and a `#` line in a run block is shell text whose `${{ }}` expands before Bash sees it. The approver login is read from the RISK_GATE_OWNER repository variable; an unset variable accepts no label. Job names, any workflow rename, runtime config files and the dependency policy script are protected, and a shared head fails closed with the leftover error refreshed when the head changes or the duplicate closes. A periodic sweep covers merges made with GITHUB_TOKEN. --- .github/workflows/risk-gate.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/risk-gate.yml b/.github/workflows/risk-gate.yml index 52d7836..6e427db 100644 --- a/.github/workflows/risk-gate.yml +++ b/.github/workflows/risk-gate.yml @@ -107,8 +107,12 @@ jobs: // 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. - if (/^\.github\/scripts\/dependabot-policy\.[^/]+$/.test(path)) return true; + // 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; if (/(^|[\/_.-])(trad(?:e|es|ing)|execution|executors?|orders?|brokers?|risk|deploy(?:ment)?|secrets?|credentials?|iam|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)$/.test(p);