diff --git a/.github/engineering.md b/.github/engineering.md index 690744a..fa938b9 100644 --- a/.github/engineering.md +++ b/.github/engineering.md @@ -49,12 +49,12 @@ ## 凭据泄漏预防 -不得提交凭据;使用 GitHub Secrets 或环境变量注入。必需的 `Gitleaks` 检查保留原名,使用 `.gitleaks.toml` 扫描新增提交,补充 MongoDB 带密码 URI、Alibaba AccessKey Secret 和配置赋值规则。测试目录只豁免明确合成的值,不整体跳过测试代码。 +不得提交凭据;使用 GitHub Secrets 或环境变量注入。必需的 `Gitleaks` 检查保留原名,由组织统一门禁 `GdeiAssistant/.github/pr-governance` 随包提供 `.gitleaks.toml`,扫描新增提交,补充 MongoDB 带密码 URI、Alibaba AccessKey Secret 和配置赋值规则。测试目录只豁免明确合成的值,不整体跳过测试代码。 -每周一 UTC 04:23 和手动触发的 `secret-scan-weekly.yml` 获取所有分支并扫描完整历史。`.gitleaks-baseline.json` 仅保存既有发现的规则、路径、提交、行号、fingerprint 和固定脱敏标记;它不豁免新提交。历史凭据仍须由所有者轮换,baseline 不代表凭据安全。 +组织统一门禁提供完整历史扫描,要求仓库 Secret `SECRET_SCAN_BASELINE_B64`。本仓库尚未配置该 Secret,因此定时历史扫描保持关闭;新增提交仍由 `Gitleaks` 检查覆盖。历史凭据仍须由所有者轮换,baseline 不代表凭据安全。 -Gitleaks 8.30.1 的原生 baseline 还比较作者、日期及位置元数据,不能直接使用精简记录。`secret_scan.py` 从本次脱敏扫描中按完整身份匹配已知项,临时补齐原生 baseline,再用 `--baseline-path` 复扫。原始报告和临时文件不上传;摘要仅含路径、规则和短提交号,保留三天。新增或修改 baseline 必须单独核对,不自动扩大历史豁免。 +Gitleaks 8.30.1 的原生 baseline 还比较作者、日期及位置元数据,不能直接使用精简记录。统一门禁的 统一门禁的 统一门禁的 统一门禁的 `secret_scan.py` 从本次脱敏扫描中按完整身份匹配已知项,临时补齐原生 baseline,再用 `--baseline-path` 复扫。原始报告和临时文件不上传;摘要仅含路径、规则和短提交号,保留三天。新增或修改 baseline 必须单独核对,不自动扩大历史豁免。 每次上传报告、日志或混合产物前,先用同一配置扫描上传的精确路径;发现凭据或扫描错误则阻止该次上传。缺失路径沿用原上传步骤的缺失文件行为。只含 APK/AAB、已编译模拟器应用 ZIP、IPA/dSYM 的步骤不做无意义的二进制文本扫描;二进制安全仍须独立的制品分析。不要将明文日志或配置加入这些例外。 -本地验证:`python3 .github/scripts/test_secret_scan.py`,以及 `python3 .github/scripts/secret_scan.py history --summary /tmp/secret-summary.json`。新规则需要用运行时生成的合成凭据测试;不得把真实值放进测试或终端输出。Gitleaks 安装固定 8.30.1,并按平台校验已固定的 SHA-256。 +本地验证:在组织治理仓库检出中运行 `python3 pr-governance/scripts/test_secret_scan.py`;仓库内的 `.github/scripts/secret_scan.py` 只用于上传前扫描精确制品路径。新规则需要用运行时生成的合成凭据测试;不得把真实值放进测试或终端输出。Gitleaks 安装固定 8.30.1,并按平台校验已固定的 SHA-256。 diff --git a/.github/governance/policy.json b/.github/governance/policy.json new file mode 100644 index 0000000..4398ad4 --- /dev/null +++ b/.github/governance/policy.json @@ -0,0 +1,14 @@ +{ + "version": "pr-governance/v3", + "mode": "enforce", + "reviewer": { + "provider": "codex", + "app_id": 1144995, + "user_id": 199175422, + "login": "chatgpt-codex-connector[bot]" + }, + "r0_paths": [], + "r2_paths": [], + "dependabot_template_exemption": true, + "max_evidence_age_hours": 168 +} diff --git a/.github/workflows/codex-nudge.yml b/.github/workflows/codex-nudge.yml new file mode 100644 index 0000000..27e5c28 --- /dev/null +++ b/.github/workflows/codex-nudge.yml @@ -0,0 +1,29 @@ +# Shared governance action: GdeiAssistant/.github/pr-governance@b25f3dd3e5c78905c2302b342af984f6ef186466. +# Keep the job names; they are required status check contexts. + +name: Review request + +on: + schedule: + - cron: '*/30 * * * *' + workflow_dispatch: + +permissions: + contents: read + pull-requests: read + issues: write + +concurrency: + group: review-request + cancel-in-progress: false + +jobs: + nudge: + name: Request a review + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Request + uses: GdeiAssistant/.github/pr-governance@b25f3dd3e5c78905c2302b342af984f6ef186466 + with: + mode: nudge diff --git a/.github/workflows/engineering-governance.yml b/.github/workflows/engineering-governance.yml index 7dfdc26..e2e3a6f 100644 --- a/.github/workflows/engineering-governance.yml +++ b/.github/workflows/engineering-governance.yml @@ -9,38 +9,6 @@ permissions: contents: read jobs: - gitleaks: - name: Gitleaks - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - persist-credentials: false - - name: Install verified Gitleaks CLI - run: bash .github/scripts/install-gitleaks.sh - - name: Test secret detection and baseline behavior - env: - GITLEAKS_BIN: ${{ runner.temp }}/gitleaks-bin/gitleaks - run: python3 .github/scripts/test_secret_scan.py - - name: Scan added commits with redacted findings - shell: bash - env: - PR_BASE: ${{ github.event.pull_request.base.sha }} - PR_HEAD: ${{ github.event.pull_request.head.sha }} - PUSH_BASE: ${{ github.event.before }} - PUSH_HEAD: ${{ github.sha }} - run: | - set -euo pipefail - base="${PR_BASE:-$PUSH_BASE}" - head="${PR_HEAD:-$PUSH_HEAD}" - if [[ -z "$base" || "$base" =~ ^0+$ ]]; then - echo "No base commit is available for this event" >&2 - exit 1 - fi - "$RUNNER_TEMP/gitleaks-bin/gitleaks" git . --config .gitleaks.toml --redact --no-banner --log-opts="$base..$head" - commitlint: name: Commit message convention runs-on: ubuntu-latest diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml new file mode 100644 index 0000000..24a6d0c --- /dev/null +++ b/.github/workflows/gitleaks.yml @@ -0,0 +1,27 @@ +# Shared governance action: GdeiAssistant/.github/pr-governance@b25f3dd3e5c78905c2302b342af984f6ef186466. +# Keep the job names; they are required status check contexts. + +name: Secret scan + +on: + pull_request: + push: + branches: [main, master] + +permissions: + contents: read + +jobs: + gitleaks: + name: Gitleaks + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - name: Scan added commits + uses: GdeiAssistant/.github/pr-governance@b25f3dd3e5c78905c2302b342af984f6ef186466 + with: + mode: scan diff --git a/.github/workflows/pr-review-signal.yml b/.github/workflows/pr-review-signal.yml new file mode 100644 index 0000000..55e94af --- /dev/null +++ b/.github/workflows/pr-review-signal.yml @@ -0,0 +1,22 @@ +# Shared governance action: GdeiAssistant/.github/pr-governance@b25f3dd3e5c78905c2302b342af984f6ef186466. +# Keep the job names; they are required status check contexts. + +name: PR review signal + +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 6e427db..b99c84e 100644 --- a/.github/workflows/risk-gate.yml +++ b/.github/workflows/risk-gate.yml @@ -1,18 +1,23 @@ +# Shared governance action: GdeiAssistant/.github/pr-governance@b25f3dd3e5c78905c2302b342af984f6ef186466. +# Keep the job names; they are required status check contexts. + name: PR risk gate 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 +27,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,388 +36,55 @@ permissions: actions: write jobs: + # 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' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + issues: read + pull-requests: read + statuses: write + steps: + - name: Record label head + uses: GdeiAssistant/.github/pr-governance@b25f3dd3e5c78905c2302b342af984f6ef186466 + env: + GOVERNANCE_OWNER_ID: ${{ vars.RISK_GATE_OWNER_ID }} + GOVERNANCE_OWNER_LOGIN: ${{ vars.RISK_GATE_OWNER }} + with: + mode: gate + evaluate: name: Evaluate PR governance - if: github.event_name != 'issue_comment' || (github.event.issue.pull_request && (github.event.comment.user.login == 'chatgpt-codex-connector[bot]' || github.event.comment.user.login == 'chatgpt-codex-connector')) + needs: record_label + # A skipped recorder is the normal case for every other event. + if: >- + always() && needs.record_label.result != 'cancelled' && + (github.event_name != 'issue_comment' || + (github.event.issue.pull_request && + (github.event.comment.user.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. + 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: GdeiAssistant/.github/pr-governance@b25f3dd3e5c78905c2302b342af984f6ef186466 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 (/(^|\/)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; - 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 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 - // 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); - } - // 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 }); - 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 }); - // 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, 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'); - 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']; - 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; - }; - // 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 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-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) { - 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 - // 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 diff --git a/.github/workflows/secret-scan-weekly.yml b/.github/workflows/secret-scan-weekly.yml deleted file mode 100644 index b5c0c78..0000000 --- a/.github/workflows/secret-scan-weekly.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Weekly secret scan - -on: - schedule: - - cron: '23 4 * * 1' - workflow_dispatch: - -permissions: - contents: read - -jobs: - history: - name: Full-history secret scan - runs-on: ubuntu-latest - timeout-minutes: 20 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - persist-credentials: false - - name: Fetch all remote branches - run: git fetch origin '+refs/heads/*:refs/remotes/origin/*' - - name: Install verified Gitleaks CLI - run: bash .github/scripts/install-gitleaks.sh - - name: Scan all history against redacted baseline - id: scan - run: >- - python3 .github/scripts/secret_scan.py history - --gitleaks "$RUNNER_TEMP/gitleaks-bin/gitleaks" - --summary "$RUNNER_TEMP/secret-summary.json" - - name: Scan redacted summary before upload - id: artifact_scan - if: ${{ !cancelled() && steps.scan.outcome != 'skipped' }} - env: - ARTIFACT_PATHS: ${{ runner.temp }}/secret-summary.json - run: >- - python3 .github/scripts/secret_scan.py artifacts - --gitleaks "$RUNNER_TEMP/gitleaks-bin/gitleaks" - - name: Upload redacted finding identities only - if: ${{ !cancelled() && steps.artifact_scan.outcome == 'success' }} - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 - with: - name: secret-scan-summary - path: ${{ runner.temp }}/secret-summary.json - if-no-files-found: error - retention-days: 3