diff --git a/.github/workflows/pr-governance-tests.yml b/.github/workflows/pr-governance-tests.yml
new file mode 100644
index 0000000..8d07c9c
--- /dev/null
+++ b/.github/workflows/pr-governance-tests.yml
@@ -0,0 +1,34 @@
+name: Tests
+
+# The v1 branch is the trust anchor for every consumer repository, so this
+# workflow is its required status check. Nothing reaches v1 without it.
+
+on:
+ pull_request:
+ push:
+ branches: [main, v1]
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ name: test
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - name: Test the gate, labels, reviewer evidence, runner and review request
+ run: >-
+ node --test pr-governance/scripts/test-gate.cjs
+ pr-governance/scripts/test-labels.cjs pr-governance/scripts/test-codex.cjs
+ pr-governance/scripts/test-main.cjs pr-governance/scripts/test-runner.cjs
+ pr-governance/scripts/test-nudge.cjs
+ - name: Install the pinned scanner
+ run: bash pr-governance/scripts/install-gitleaks.sh
+ - name: Test the scanner
+ env:
+ GITLEAKS_BIN: ${{ runner.temp }}/gitleaks-bin/gitleaks
+ run: python3 pr-governance/scripts/test_secret_scan.py
diff --git a/pr-governance/.gitleaks.toml b/pr-governance/.gitleaks.toml
new file mode 100644
index 0000000..860d654
--- /dev/null
+++ b/pr-governance/.gitleaks.toml
@@ -0,0 +1,68 @@
+title = "GdeiAssistant credential detection"
+
+[extend]
+useDefault = true
+
+[[rules]]
+id = "mongodb-userinfo-password"
+description = "MongoDB URI containing a userinfo password"
+regex = '''(?i)mongodb(?:\+srv)?://[^\s:/@]+:([^\s/@]+)@'''
+secretGroup = 1
+
+[[rules.allowlists]]
+description = "Synthetic values in established test fixture directories only"
+condition = "AND"
+regexTarget = "secret"
+paths = ['''^tests/''']
+regexes = ['''(?i)^(?:test|fake|dummy|example)[_.-][a-z0-9_.-]+$''']
+
+[[rules.allowlists]]
+description = "Runtime-generated URI expression in the scanner regression source"
+condition = "AND"
+regexTarget = "secret"
+paths = ['''(?:^|/)(?:\.github/)?scripts/test_secret_scan\.py$''']
+regexes = ['''^\{fake_value\(\)\}$''']
+
+[[rules]]
+id = "alibaba-access-key-secret"
+description = "Alibaba Cloud AccessKey Secret assignment"
+regex = '''(?i)access[_-]?key(?:[_-]?secret)?["']?\s*[:=]\s*["']?([a-z0-9]{30})(?:["'\s,;]|$)'''
+secretGroup = 1
+
+[[rules.allowlists]]
+description = "Synthetic values in established test fixture directories only"
+condition = "AND"
+regexTarget = "secret"
+paths = ['''^tests/''']
+regexes = ['''(?i)^(?:test|fake|dummy|example)[_.-][a-z0-9_.-]+$''']
+
+[[rules]]
+id = "configuration-secret-value"
+description = "Non-placeholder credential in a configuration, log or JUnit XML assignment"
+path = '''(?:\.(?:properties|ya?ml|env|conf|ini|log|txt|xml)$|(?:^|/)\.env(?:\.[^/]+)?$)'''
+regex = '''(?im)(?:^||$)'''
+secretGroup = 1
+
+[[rules.allowlists]]
+description = "Synthetic values in established test fixture directories only"
+condition = "AND"
+regexTarget = "secret"
+paths = ['''^tests/''']
+regexes = ['''(?i)^(?:test|fake|dummy|example)[_.-][a-z0-9_.-]+$''']
+
+[[allowlists]]
+description = "Explicit placeholder values, never a blanket source-path exclusion"
+regexTarget = "secret"
+regexes = [
+ '''(?i)^(?:REDACTED|(?:your[_.-])?(?:secret|token|password|appsecret|access[_.-]?key)(?:[_.-](?:here|example|placeholder))?|change[_.-]?me|replace[_.-]?me|<[^>]+>|\$\{[A-Za-z_][A-Za-z0-9_]*\}|x{8,}|0{16,})$''',
+]
+
+[[rules]]
+id = "generic-api-key"
+
+[[rules.allowlists]]
+description = "Synthetic values in established test fixture directories only"
+condition = "AND"
+regexTarget = "secret"
+paths = ['''^tests/''']
+regexes = ['''(?i)^(?:test|fake|dummy|example)[_.-][a-z0-9_.-]+$''']
diff --git a/pr-governance/README.md b/pr-governance/README.md
new file mode 100644
index 0000000..b21cae3
--- /dev/null
+++ b/pr-governance/README.md
@@ -0,0 +1,125 @@
+# Organization PR governance
+
+One action and four caller workflows shared by every public repository in the
+organization. The reviewer is Codex; the coding agent may be anything. No
+workflow identifies or depends on the agent that wrote a pull request.
+
+## What is enforced
+
+A pull request may merge when all of these hold:
+
+- `pr-template` — the body fills the AI assistance, what changed and how
+ verified sections.
+- `ai-review` — the newest Codex receipt names the current head, reports a
+ completed review, has no unresolved finding, and the head contains the
+ current base head.
+- `risk-gate` — the resolved risk tier allows the path. Low risk passes on the
+ reviewer receipt. High risk also needs one clearing label that was recorded
+ on this exact head.
+- `Gitleaks` — no credential in the commits the pull request adds.
+- Repository-specific checks (`test`, `Documentation check`, `Repository
+ hygiene`, `Commit message convention`, …) stay in each repository.
+
+Branch protection, not this action, is the merge constraint. The action only
+publishes evidence; it cannot merge, label, or write repository contents.
+
+## Risk tiers
+
+| Tier | Typical paths | Requirement |
+| --- | --- | --- |
+| R0 | prose, docs | template and reviewer receipt |
+| R1 | ordinary source | template and reviewer receipt |
+| R2 | auth, secrets, deploy, runtime, strategy, config, manifests | one clearing label bound to the head |
+
+The tiers come from the engine's path rules plus the repository's `r2_paths`.
+`r0_paths` can only lower a path that has no sensitive token.
+
+Clearing labels: `owner-approved` (owner only) and `agent-approved` (any actor
+with write permission). A label added by a coding agent is evidence that the
+agent judged the change, not a substitute for the reviewer receipt. Only the
+owner may add or remove `owner-approved`; agents must never touch it.
+
+## Files
+
+Everything below lives under `pr-governance/` so it can share a repository with
+organization community-health files.
+
+| Path | Purpose |
+| --- | --- |
+| `action.yml` | `mode: gate`, `nudge` or `scan` |
+| `scripts/gate.cjs` | classification, evidence and verdict |
+| `scripts/runner.cjs` | collection, status publication, label recording |
+| `scripts/main.cjs` | trusted policy loading and mode dispatch |
+| `scripts/nudge.cjs` | one review request per head |
+| `scripts/secret_scan.py` | range, history baseline and artifact scans |
+| `scripts/install-gitleaks.sh` | checksum-verified Gitleaks 8.30.1 |
+| `.gitleaks.toml` | organization rules and narrow synthetic allowlists |
+| `policy.schema.json` | `pr-governance/v3` schema |
+| `policy.default.json` | starting policy |
+| `templates/` | caller workflows to copy into a repository |
+
+## Wiring a repository
+
+1. Copy `.github/governance/policy.json` from `policy.default.json` and set
+ `reviewer` and repository-specific `r2_paths`. Leave `owner` out: account
+ identities belong in protected configuration, so the action reads
+ `RISK_GATE_OWNER_ID` and `RISK_GATE_OWNER` from repository or organization
+ variables and replaces `owner` when they are set.
+2. Copy `templates/risk-gate.yml`, `templates/codex-nudge.yml`,
+ `templates/gitleaks.yml` and `templates/weekly-secret-scan.yml` into
+ `.github/workflows/`, replacing `__GOVERNANCE_REPO__` with the
+ `owner/repository` of this governance repository and `__GOVERNANCE_SHA__`
+ with the full commit SHA at the head of `v1`. Copy
+ `templates/pr-review-signal.yml` unchanged as well: review events on fork
+ and Dependabot pull requests get a read-only token, so the gate is started
+ by that workflow's completion instead.
+3. Add the required contexts to branch protection on the default branch:
+ `pr-template`, `ai-review`, `risk-gate`, `Gitleaks`, and the repository's
+ own checks. Keep `strict` (require branches to be up to date) and
+ `enforce_admins` on.
+4. Add the `SECRET_SCAN_BASELINE_B64` secret before enabling the weekly scan.
+ The scan refuses to run without it.
+
+The owner identity comes from the `RISK_GATE_OWNER_ID` and `RISK_GATE_OWNER`
+variables. A policy that still carries `owner` keeps working until the
+variables are set, but no account identity is required in the file.
+
+## Trust model
+
+- Workflows never check out or execute pull request code.
+- The action is pinned to a full commit SHA of this repository, not by the
+ repository under judgement. Update the pins after `v1` moves.
+- The policy is read from the default branch head, so a pull request cannot
+ change the rules that judge it.
+- Reviewer evidence is a marker comment written by the configured reviewer
+ identity. Screenshots, prose and labels are not evidence.
+- A label only counts for the head the `labeled` event named. Revoking and
+ re-adding it does not replay an older record.
+- Status writes stop before GitHub's 1000-per-context limit; the last slot is
+ reserved for a blocking state, so a head that runs out of slots stays blocked
+ instead of silently passing.
+
+## Modes
+
+`policy.json` `mode` controls the outcome:
+
+- `enforce` (default) — publish statuses and fail the run when any pull request
+ fails.
+- `report-only` — publish advisory statuses, warn instead of failing. Use for
+ the first week of a migration.
+- `local` — publish nothing. Used by the private-repository track, where branch
+ protection is unavailable.
+
+## Changing the shared action
+
+Open a pull request against `v1`. The `test` check must pass. Merging changes
+the behavior of every consumer at its next run, so the digest of the policy is
+part of every receipt: a policy change invalidates older receipts and asks for
+a fresh review instead of silently reusing one.
+
+## Private repositories
+
+GitHub Free does not provide branch protection or secret scanning for private
+repositories, and hosted Actions may be billing-blocked. Use the local track:
+the same policy file, a pre-push hook and `scripts/local` commands recording
+the same receipt and scan results locally. See `docs/private-repositories.md`.
diff --git a/pr-governance/action.yml b/pr-governance/action.yml
new file mode 100644
index 0000000..9232e55
--- /dev/null
+++ b/pr-governance/action.yml
@@ -0,0 +1,97 @@
+name: PR governance
+description: Evaluate pull request governance evidence, request a Codex review, or scan for secrets.
+inputs:
+ mode:
+ description: "gate, nudge or scan"
+ required: true
+ full-history:
+ description: "Scan every fetched branch and its complete history"
+ required: false
+ default: "false"
+ artifact-name:
+ description: "Artifact name for the aggregate-only history summary"
+ required: false
+ default: "secret-scan-summary"
+ record-label:
+ description: "Record the head a clearing label was added to instead of evaluating"
+ required: false
+ default: "false"
+outputs: {}
+runs:
+ using: composite
+ steps:
+ - name: Evaluate pull request governance
+ if: inputs.mode == 'gate'
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GOVERNANCE_ACTION_PATH: ${{ github.action_path }}
+ GOVERNANCE_RECORD_LABEL: ${{ inputs.record-label }}
+ with:
+ script: |
+ const path = require('node:path');
+ const { runGate } = require(path.join(process.env.GOVERNANCE_ACTION_PATH, 'scripts', 'main.cjs'));
+ await runGate({ github, context, core });
+
+ - name: Request a Codex review when the current head has no receipt
+ if: inputs.mode == 'nudge'
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ env:
+ GOVERNANCE_ACTION_PATH: ${{ github.action_path }}
+ with:
+ script: |
+ const path = require('node:path');
+ const { run } = require(path.join(process.env.GOVERNANCE_ACTION_PATH, 'scripts', 'nudge.cjs'));
+ await run({ github, context, core });
+
+ - name: Install checksum-verified Gitleaks
+ if: inputs.mode == 'scan'
+ shell: bash
+ env:
+ GOVERNANCE_ACTION_PATH: ${{ github.action_path }}
+ run: bash "$GOVERNANCE_ACTION_PATH/scripts/install-gitleaks.sh"
+
+ - name: Scan
+ if: inputs.mode == 'scan'
+ shell: bash
+ env:
+ GOVERNANCE_ACTION_PATH: ${{ github.action_path }}
+ FULL_HISTORY: ${{ inputs.full-history }}
+ BASELINE_B64: ${{ env.SECRET_SCAN_BASELINE_B64 }}
+ run: |
+ set -euo pipefail
+ binary="$RUNNER_TEMP/gitleaks-bin/gitleaks"
+ if [[ "$FULL_HISTORY" == "true" ]]; then
+ if [[ -z "${BASELINE_B64:-}" ]]; then
+ echo "SECRET_SCAN_BASELINE_B64 is not configured; refusing to scan without a baseline" >&2
+ exit 1
+ fi
+ printf '%s' "$BASELINE_B64" | base64 --decode > "$RUNNER_TEMP/secret-baseline.json"
+ python3 "$GOVERNANCE_ACTION_PATH/scripts/secret_scan.py" history \
+ --root "$GITHUB_WORKSPACE" \
+ --gitleaks "$binary" \
+ --baseline "$RUNNER_TEMP/secret-baseline.json" \
+ --summary "$RUNNER_TEMP/secret-summary.json"
+ ARTIFACT_PATHS="$RUNNER_TEMP/secret-summary.json" \
+ python3 "$GOVERNANCE_ACTION_PATH/scripts/secret_scan.py" artifacts --gitleaks "$binary"
+ exit 0
+ fi
+ base="$(jq -r '.pull_request.base.sha // .before // empty' "$GITHUB_EVENT_PATH")"
+ head="$(jq -r '.pull_request.head.sha // .after // .sha // empty' "$GITHUB_EVENT_PATH")"
+ if [[ -z "$base" || "$base" =~ ^0+$ ]]; then
+ echo "No base commit is available for this event; the pull request scan covers the branch" >&2
+ exit 0
+ fi
+ python3 "$GOVERNANCE_ACTION_PATH/scripts/secret_scan.py" range \
+ --root "$GITHUB_WORKSPACE" \
+ --base "$base" \
+ --head "$head" \
+ --gitleaks "$binary"
+
+ - name: Upload aggregate-only history summary
+ if: inputs.mode == 'scan' && inputs.full-history == 'true'
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
+ with:
+ name: ${{ inputs.artifact-name }}
+ path: ${{ runner.temp }}/secret-summary.json
+ if-no-files-found: error
+ retention-days: 3
diff --git a/pr-governance/policy.default.json b/pr-governance/policy.default.json
new file mode 100644
index 0000000..abb4451
--- /dev/null
+++ b/pr-governance/policy.default.json
@@ -0,0 +1,24 @@
+{
+ "version": "pr-governance/v3",
+ "mode": "enforce",
+ "reviewer": {
+ "provider": "codex",
+ "app_id": 1144995,
+ "user_id": 199175422,
+ "login": "chatgpt-codex-connector[bot]"
+ },
+ "r0_paths": [
+ "README.md",
+ "CHANGELOG.md",
+ "docs/prose/"
+ ],
+ "r2_paths": [
+ "scripts/",
+ ".github/governance/",
+ ".github/scripts/",
+ "policy.schema.json",
+ "policy.default.json"
+ ],
+ "dependabot_template_exemption": true,
+ "max_evidence_age_hours": 168
+}
diff --git a/pr-governance/policy.schema.json b/pr-governance/policy.schema.json
new file mode 100644
index 0000000..b9a80b3
--- /dev/null
+++ b/pr-governance/policy.schema.json
@@ -0,0 +1,109 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "pr-governance/v3",
+ "title": "PR governance policy",
+ "description": "Trusted per-repository policy read from the default branch head. Never read from the pull request head.",
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "version",
+ "mode",
+ "reviewer",
+ "r0_paths",
+ "r2_paths",
+ "dependabot_template_exemption",
+ "max_evidence_age_hours"
+ ],
+ "properties": {
+ "version": {
+ "const": "pr-governance/v3"
+ },
+ "mode": {
+ "description": "enforce publishes and fails; report-only publishes advisory statuses and never fails the run; local publishes nothing.",
+ "enum": [
+ "enforce",
+ "local",
+ "report-only"
+ ]
+ },
+ "reviewer": {
+ "description": "The only fixed identity in the gate. Reviewer-authored evidence is the sole review source.",
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "provider",
+ "app_id",
+ "user_id",
+ "login"
+ ],
+ "properties": {
+ "provider": {
+ "enum": [
+ "codex"
+ ]
+ },
+ "app_id": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "user_id": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "login": {
+ "type": "string",
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9-]*\\[bot\\]$"
+ }
+ }
+ },
+ "owner": {
+ "description": "Optional. The only actor whose label event can claim owner-approved. Prefer setting RISK_GATE_OWNER_ID and RISK_GATE_OWNER as repository or organization variables; the action then replaces this object and the policy carries no account identity.",
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "user_id",
+ "login"
+ ],
+ "properties": {
+ "user_id": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "login": {
+ "type": "string",
+ "minLength": 1
+ }
+ }
+ },
+ "r0_paths": {
+ "description": "Prose paths that may be classified R0 when the change has no sensitive keyword. Trailing slash matches a prefix.",
+ "type": "array",
+ "uniqueItems": true,
+ "items": {
+ "type": "string",
+ "pattern": "^[^/\\\\][^\\\\]*$",
+ "minLength": 1
+ }
+ },
+ "r2_paths": {
+ "description": "Repository-specific high-risk paths; added to the canonical heuristics.",
+ "type": "array",
+ "uniqueItems": true,
+ "items": {
+ "type": "string",
+ "pattern": "^[^/\\\\][^\\\\]*$",
+ "minLength": 1
+ }
+ },
+ "dependabot_template_exemption": {
+ "description": "Skip the pull request template check for verified Dependabot heads.",
+ "type": "boolean"
+ },
+ "max_evidence_age_hours": {
+ "description": "Maximum age of reviewer evidence, in hours.",
+ "type": "number",
+ "minimum": 1,
+ "maximum": 720
+ }
+ }
+}
diff --git a/pr-governance/scripts/fixtures.cjs b/pr-governance/scripts/fixtures.cjs
new file mode 100644
index 0000000..4401f57
--- /dev/null
+++ b/pr-governance/scripts/fixtures.cjs
@@ -0,0 +1,157 @@
+'use strict';
+
+// Synthetic fixtures shared by the test suites. Every identifier is either a
+// documented public constant from policy.default.json or an obviously fake one;
+// no real credential, comment body or personal data appears here.
+
+const policy = require('../policy.default.json');
+
+const HEAD = 'a'.repeat(40);
+const BASE = 'b'.repeat(40);
+const POLICY_SHA = 'c'.repeat(64);
+const NOW = 1_800_000_000;
+const COMPLETE_TEMPLATE = '## AI assistance\nCodex was used for the gate.\n'
+ + '## What changed\nBind reviewer evidence to the head.\n'
+ + '## How verified\nOffline tests passed.';
+
+const at = (offsetSeconds) => new Date((NOW + offsetSeconds) * 1000).toISOString();
+
+function fixture(options = {}) {
+ const clone = structuredClone(policy);
+ // The shipped policy leaves the owner to protected variables, so fixtures
+ // inject a synthetic one for the label and digest assertions.
+ clone.owner ??= { user_id: 101, login: 'synthetic-owner' };
+ if (options.policy) options.policy(clone);
+ const files = options.files || [{
+ filename: options.path || 'src/view.js',
+ status: 'modified',
+ sha: 'd'.repeat(40),
+ additions: 1,
+ deletions: 1,
+ patch: '@@ -1 +1 @@\n-before\n+after',
+ }];
+ const commits = options.commits || [{
+ author: { id: 7, login: 'author' },
+ committer: { id: 7, login: 'author' },
+ commit: { verification: { verified: false }, committer: { email: 'author@example.invalid' } },
+ }];
+ return {
+ snapshot: {
+ repository: 'example-org/example-repo',
+ policyCommit: BASE,
+ complete: true,
+ pr: {
+ state: 'open',
+ number: options.number || 42,
+ user: { id: 7, login: 'author', type: 'User' },
+ body: options.body ?? COMPLETE_TEMPLATE,
+ labels: [],
+ head: { sha: HEAD },
+ base: { sha: BASE, ref: 'main', repo: { id: 100 } },
+ changed_files: files.length,
+ commits: commits.length,
+ },
+ files,
+ commits,
+ comments: [],
+ threads: [],
+ timeline: [],
+ statuses: [],
+ dependabotPolicy: options.dependabotPolicy ?? null,
+ },
+ policy: clone,
+ };
+}
+
+const reviewerUser = clone => ({ id: clone.policy.reviewer.user_id, login: clone.policy.reviewer.login, type: 'Bot' });
+
+// `codex-review-state:v1` — the canonical inline format.
+function stateMarker(clone, review = {}, options = {}) {
+ const body = {
+ code_review: {
+ commit_sha: HEAD,
+ has_any_findings: false,
+ has_unresolved_findings: false,
+ status: 'completed',
+ ...review,
+ },
+ security_review: {},
+ };
+ clone.snapshot.comments.push({
+ id: options.id ?? 900 + clone.snapshot.comments.length,
+ user: reviewerUser(clone),
+ created_at: at(options.updated ?? -60),
+ updated_at: at(options.updated ?? -60),
+ body: `\n\n## Review summary\n`,
+ });
+ return clone;
+}
+
+// `codex-review-receipt:v1` — the module format.
+function receiptMarker(clone, review = {}, options = {}) {
+ const body = {
+ review: { commit_sha: HEAD, coverage: 'complete', unresolved_findings: 0, ...review },
+ };
+ clone.snapshot.comments.push({
+ id: options.id ?? 900 + clone.snapshot.comments.length,
+ user: reviewerUser(clone),
+ created_at: at(options.updated ?? -60),
+ updated_at: at(options.updated ?? -60),
+ body: `\n\nReview finished.\n`,
+ });
+ return clone;
+}
+
+function rawComment(clone, body, options = {}) {
+ clone.snapshot.comments.push({
+ id: options.id ?? 900 + clone.snapshot.comments.length,
+ user: options.user || reviewerUser(clone),
+ created_at: at(options.updated ?? -60),
+ updated_at: at(options.updated ?? -60),
+ body,
+ });
+ return clone;
+}
+
+function thread(clone, options = {}) {
+ clone.snapshot.threads.push({
+ id: options.id || 'PRRT_thread_1',
+ isResolved: options.resolved === true,
+ isOutdated: options.outdated === true,
+ author: { login: options.author ?? clone.policy.reviewer.login.replace(/\[bot\]$/, '') },
+ });
+ return clone;
+}
+
+// A timeline label event plus the label it leaves on the pull request.
+function labelEvent(clone, options = {}) {
+ const id = options.id ?? 5000 + clone.snapshot.timeline.length;
+ const name = options.name || 'owner-approved';
+ const event = options.event || 'labeled';
+ const actor = options.actor || { id: clone.policy.owner.user_id, login: clone.policy.owner.login };
+ clone.snapshot.timeline.push({ id, event, label: { name }, actor, created_at: at(options.at ?? -300) });
+ const present = clone.snapshot.pr.labels.filter(item => item.name !== name);
+ clone.snapshot.pr.labels = event === 'labeled' ? [...present, { name }] : present;
+ return { id, name, actor, event };
+}
+
+// The immutable `clearing-label` record written by the record_label step.
+function clearRecord(clone, record, options = {}) {
+ clone.snapshot.statuses.push({
+ context: 'clearing-label',
+ state: 'success',
+ description: options.description
+ ?? `${record.name} for PR ${clone.snapshot.pr.number} at event ${record.id} on this head`,
+ creator: { id: 41898282, login: 'github-actions[bot]' },
+ });
+ return clone;
+}
+
+function approve(clone) {
+ return receiptMarker(clone);
+}
+
+module.exports = {
+ policy, HEAD, BASE, POLICY_SHA, NOW, COMPLETE_TEMPLATE, at,
+ fixture, stateMarker, receiptMarker, rawComment, thread, labelEvent, clearRecord, approve,
+};
diff --git a/pr-governance/scripts/gate.cjs b/pr-governance/scripts/gate.cjs
new file mode 100644
index 0000000..04cc704
--- /dev/null
+++ b/pr-governance/scripts/gate.cjs
@@ -0,0 +1,561 @@
+'use strict';
+
+// pr-governance/v3 evaluator.
+//
+// Pure functions only: no I/O, no timers and no Node built-ins. This file is
+// embedded verbatim into the generated reusable workflow, so it has to run in
+// any JavaScript host with nothing but the language itself.
+
+const SCHEMA = 'pr-governance/v3';
+const MODES = ['enforce', 'local', 'report-only'];
+const POLICY_FIELDS = ['version', 'mode', 'reviewer', 'r0_paths', 'r2_paths',
+ 'dependabot_template_exemption', 'max_evidence_age_hours'];
+const POLICY_OPTIONAL_FIELDS = ['owner'];
+const REVIEWER_FIELDS = ['provider', 'app_id', 'user_id', 'login'];
+const OWNER_FIELDS = ['user_id', 'login'];
+const PROVIDERS = ['codex'];
+const MARKER_NAMES = ['codex-review-receipt:v1', 'codex-review-state:v1'];
+const COMPLETE_COVERAGE = ['complete', 'completed', 'full', 'reported_complete', 'all'];
+const DEPENDABOT = 'dependabot[bot]';
+const CLEARING_LABELS = ['owner-approved', 'agent-approved'];
+
+const assert = (condition, message) => { if (!condition) throw new Error(message); };
+
+// ---------------------------------------------------------------- digests ---
+
+function utf8Bytes(text) {
+ const bytes = [];
+ for (let index = 0; index < text.length; index++) {
+ const point = text.codePointAt(index);
+ if (point > 0xffff) index++;
+ if (point < 0x80) bytes.push(point);
+ else if (point < 0x800) bytes.push(0xc0 | (point >> 6), 0x80 | (point & 63));
+ else if (point < 0x10000) bytes.push(0xe0 | (point >> 12), 0x80 | ((point >> 6) & 63), 0x80 | (point & 63));
+ else bytes.push(0xf0 | (point >> 18), 0x80 | ((point >> 12) & 63), 0x80 | ((point >> 6) & 63), 0x80 | (point & 63));
+ }
+ return Uint8Array.from(bytes);
+}
+
+const ROUND = [
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
+];
+
+const rotate = (value, bits) => (value >>> bits) | (value << (32 - bits));
+
+function sha256Hex(text) {
+ const bytes = utf8Bytes(String(text));
+ const total = Math.ceil((bytes.length + 9) / 64) * 64;
+ const block = new Uint8Array(total);
+ block.set(bytes);
+ block[bytes.length] = 0x80;
+ const view = new DataView(block.buffer);
+ view.setUint32(total - 8, Math.floor((bytes.length * 8) / 0x100000000));
+ view.setUint32(total - 4, (bytes.length * 8) >>> 0);
+ const state = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
+ 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];
+ const schedule = new Uint32Array(64);
+ for (let offset = 0; offset < total; offset += 64) {
+ for (let index = 0; index < 16; index++) schedule[index] = view.getUint32(offset + index * 4);
+ for (let index = 16; index < 64; index++) {
+ const a = schedule[index - 15];
+ const b = schedule[index - 2];
+ const s0 = rotate(a, 7) ^ rotate(a, 18) ^ (a >>> 3);
+ const s1 = rotate(b, 17) ^ rotate(b, 19) ^ (b >>> 10);
+ schedule[index] = (schedule[index - 16] + s0 + schedule[index - 7] + s1) >>> 0;
+ }
+ let [a, b, c, d, e, f, g, h] = state;
+ for (let index = 0; index < 64; index++) {
+ const s1 = rotate(e, 6) ^ rotate(e, 11) ^ rotate(e, 25);
+ const choice = (e & f) ^ (~e & g);
+ const t1 = (h + s1 + choice + ROUND[index] + schedule[index]) >>> 0;
+ const s0 = rotate(a, 2) ^ rotate(a, 13) ^ rotate(a, 22);
+ const majority = (a & b) ^ (a & c) ^ (b & c);
+ const t2 = (s0 + majority) >>> 0;
+ h = g; g = f; f = e; e = (d + t1) >>> 0;
+ d = c; c = b; b = a; a = (t1 + t2) >>> 0;
+ }
+ state[0] = (state[0] + a) >>> 0; state[1] = (state[1] + b) >>> 0;
+ state[2] = (state[2] + c) >>> 0; state[3] = (state[3] + d) >>> 0;
+ state[4] = (state[4] + e) >>> 0; state[5] = (state[5] + f) >>> 0;
+ state[6] = (state[6] + g) >>> 0; state[7] = (state[7] + h) >>> 0;
+ }
+ return state.map(value => value.toString(16).padStart(8, '0')).join('');
+}
+
+// Sorted keys and no undefined leaves: the same evidence always hashes the same.
+function canonical(value) {
+ if (value === undefined) return 'null';
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
+ if (value && typeof value === 'object') {
+ return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
+ }
+ return JSON.stringify(value);
+}
+
+const digest = value => sha256Hex(canonical(value));
+const isSha = value => typeof value === 'string' && /^[a-f0-9]{40}$/.test(value);
+const matchesPath = (path, rule) => rule.endsWith('/') ? path.startsWith(rule) : path === rule;
+const normalizeLogin = login => String(login || '').replace(/\[bot\]$/i, '').toLowerCase();
+
+// ----------------------------------------------------------------- policy ---
+
+function sameKeys(object, expected, label, optional = []) {
+ assert(object && typeof object === 'object' && !Array.isArray(object), `Invalid ${label}`);
+ const allowed = new Set([...expected, ...optional]);
+ const actual = Object.keys(object);
+ assert(actual.every(key => allowed.has(key))
+ && expected.every(key => Object.prototype.hasOwnProperty.call(object, key)),
+ `Invalid ${label} fields`);
+}
+
+const isPositiveId = value => Number.isSafeInteger(value) && value > 0;
+
+function validatePolicy(policy) {
+ sameKeys(policy, POLICY_FIELDS, 'policy', POLICY_OPTIONAL_FIELDS);
+ assert(policy.version === SCHEMA, `Unsupported policy version: ${String(policy.version)}`);
+ assert(MODES.includes(policy.mode), `Unsupported policy mode: ${String(policy.mode)}`);
+ sameKeys(policy.reviewer, REVIEWER_FIELDS, 'reviewer');
+ assert(PROVIDERS.includes(policy.reviewer.provider), `Unsupported reviewer provider: ${String(policy.reviewer.provider)}`);
+ assert(isPositiveId(policy.reviewer.app_id), 'Invalid reviewer app id');
+ assert(isPositiveId(policy.reviewer.user_id), 'Invalid reviewer user id');
+ assert(typeof policy.reviewer.login === 'string' && /^[A-Za-z0-9][A-Za-z0-9-]*\[bot\]$/.test(policy.reviewer.login),
+ 'Invalid reviewer login');
+ // The owner comes from protected repository variables, so the committed
+ // policy may leave it out. When it is present, it must be well formed.
+ if (policy.owner !== undefined) {
+ sameKeys(policy.owner, OWNER_FIELDS, 'owner');
+ assert(isPositiveId(policy.owner.user_id), 'Invalid owner user id');
+ assert(typeof policy.owner.login === 'string' && policy.owner.login.length > 0, 'Invalid owner login');
+ }
+ for (const field of ['r0_paths', 'r2_paths']) {
+ const rules = policy[field];
+ assert(Array.isArray(rules), `Invalid ${field}`);
+ assert(new Set(rules).size === rules.length, `Duplicate rule in ${field}`);
+ for (const rule of rules) {
+ assert(typeof rule === 'string' && rule.length > 0 && !rule.startsWith('/') && !rule.includes('\\')
+ && !rule.replace(/\/$/, '').split('/').some(part => part === '..' || part === '.' || part === ''),
+ `Invalid ${field} rule: ${String(rule)}`);
+ }
+ }
+ assert(typeof policy.dependabot_template_exemption === 'boolean', 'Invalid dependabot_template_exemption');
+ assert(typeof policy.max_evidence_age_hours === 'number' && policy.max_evidence_age_hours >= 1
+ && policy.max_evidence_age_hours <= 720, 'Invalid max_evidence_age_hours');
+ return policy;
+}
+
+// --------------------------------------------------------- path classifyng ---
+
+const PROTECTED_TOKEN = new RegExp('(^|[/_.-])(auth(?:entication|orization)?|oauth|login|session|tokens?'
+ + '|credentials?|secrets?|iam|permissions?|privacy|storage|trad(?:e|es|ing)|execut(?:ion|or)s?|orders?'
+ + '|brokers?|risk|deploy(?:ment)?|release|runtime|strateg(?:y|ies)|governance|settings?|config(?:uration)?)'
+ + '([/_.-]|$)');
+const MANIFEST = new RegExp('(^|/)(dockerfile(?:\\..*)?|docker-compose[^/]*|compose\\.ya?ml|vercel\\.json'
+ + '|wrangler\\.(?:toml|jsonc?)|firebase\\.json|package(?:-lock)?\\.json|requirements[^/]*|pyproject\\.toml'
+ + '|setup\\.(?:py|cfg)|uv\\.lock|poetry\\.lock|yarn\\.lock|pnpm-lock\\.yaml|makefile|.*\\.config\\.[^/]+)$');
+const GOVERNANCE_SCRIPT = /^scripts\/[^/]*(?:check|verify|validate|hygiene|lint)[^/]*\.(?:py|sh|js|cjs|mjs|ts)$/;
+const SECRET_MATERIAL = /\.(?:tf|tfvars|pem|key|p12|pfx)$/;
+
+function pathTier(path, policy) {
+ assert(typeof path === 'string' && path.length > 0 && !path.startsWith('/') && !path.includes('\\')
+ && !path.split('/').some(part => part === '..' || part === '.' || part === ''), 'Unknown path');
+ const lowered = path.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
+ // Protected paths outrank prose, examples and fixtures: a document that names
+ // a credential is not low risk.
+ if (lowered.startsWith('.github/')
+ || /(^|\/)(agents|claude)\.md$/.test(lowered)
+ || /(^|\/)(codeowners|\.coderabbit\.ya?ml|\.env(?:[._-].*)?)$/.test(lowered)
+ || PROTECTED_TOKEN.test(lowered)
+ || MANIFEST.test(lowered)
+ || GOVERNANCE_SCRIPT.test(lowered)
+ || SECRET_MATERIAL.test(lowered)
+ || policy.r2_paths.some(rule => matchesPath(path, rule))) return 'R2';
+ return policy.r0_paths.some(rule => matchesPath(path, rule)) && /\.(?:md|txt)$/.test(lowered) ? 'R0' : 'R1';
+}
+
+const isWorkflowPath = path => path.startsWith('.github/workflows/');
+
+// A Dependabot repin keeps the action and changes only its ref. Swapping in a
+// different action is a different change and takes the normal tier.
+function pureActionRepin(file) {
+ if (typeof file.patch !== 'string' || !file.patch) return false;
+ const changed = file.patch.split('\n')
+ .filter(line => /^[+-]/.test(line) && !/^(?:\+\+\+|---)/.test(line))
+ .map(line => ({ sign: line[0], text: line.slice(1) }));
+ const pinned = changed.filter(entry => /^\s*-?\s*uses\s*:/.test(entry.text));
+ if (!pinned.length || pinned.length !== changed.length) return false;
+ const targets = sign => pinned.filter(entry => entry.sign === sign)
+ .map(entry => /^\s*-?\s*uses\s*:\s*["']?([^@"'\s]+)@/.exec(entry.text)?.[1] ?? null);
+ const removed = targets('-');
+ const added = targets('+');
+ return removed.length > 0 && removed.length === added.length
+ && removed.every((target, index) => target !== null && target === added[index]);
+}
+
+function classify(files, expected, policy, options = {}) {
+ assert(Array.isArray(files) && files.length > 0 && files.length === expected && files.length < 3000,
+ 'Incomplete file list');
+ assert(new Set(files.map(file => file.filename)).size === files.length, 'Duplicate file');
+ const dependabot = options.dependabot || {};
+ const allowlisted = dependabot.detected && Array.isArray(dependabot.action_files) ? dependabot.action_files : [];
+ let tier = 'R0';
+ const rules = [];
+ for (const file of files) {
+ assert(['added', 'modified', 'removed', 'renamed', 'copied', 'changed'].includes(file.status), 'Unknown file status');
+ assert(isSha(file.sha), 'Missing file object SHA');
+ assert(Number.isSafeInteger(file.additions) && file.additions >= 0
+ && Number.isSafeInteger(file.deletions) && file.deletions >= 0, 'Unknown diff size');
+ if (file.status === 'renamed') assert(file.previous_filename, 'Missing previous path');
+ const paths = [file.filename, file.previous_filename].filter(Boolean);
+ let fileTier = paths.map(path => pathTier(path, policy)).sort().at(-1);
+ // Count actual hunk lines: missing or truncated patches cannot attest coverage.
+ const patch = file.patch || '';
+ const lines = patch.split('\n');
+ assert(lines.filter(line => line.startsWith('+')).length === file.additions
+ && lines.filter(line => line.startsWith('-')).length === file.deletions, 'Incomplete patch');
+ assert(patch || (file.additions === 0 && file.deletions === 0 && file.status === 'renamed'), 'Missing patch');
+ const changed = lines.filter(line => /^[+-]/.test(line)).map(line => line.slice(1));
+ // R0 is deliberately narrow: even inline code, links and HTML need a reviewer.
+ if (fileTier === 'R0' && (file.status !== 'modified'
+ || changed.some(line => /[`~<>\[\]{}\\$]|^\s{4}|(?:https?:|javascript:|\b(?:curl|wget|npm|python|sudo|bash)\b)/i.test(line)))) fileTier = 'R1';
+ if (paths.some(path => /\.(?:md|txt)$/.test(path))
+ && changed.some(line => /\b(?:auth|iam|secret|credential|permission|trading|orders?|strategy|deploy|workflow|owner-approved)\b|认证|授权|凭据|交易|下单|部署/i.test(line))) fileTier = 'R2';
+ // A verified Dependabot update inside an allowlisted workflow is nothing but
+ // a repin, so it may merge without a clearing label. Everything else in a
+ // workflow definition still needs one.
+ if (fileTier === 'R2' && dependabot.detected && allowlisted.includes(file.filename)
+ && isWorkflowPath(file.filename) && !file.previous_filename && pureActionRepin(file)) fileTier = 'R1';
+ if (fileTier > tier) tier = fileTier;
+ rules.push({ paths, tier: fileTier });
+ }
+ return { tier, rules };
+}
+
+// --------------------------------------------------------------- evidence ---
+
+function binding(snapshot, policySha) {
+ const pr = snapshot.pr;
+ assert(isSha(pr.head.sha) && isSha(pr.base.sha), 'Unknown commit');
+ assert(isSha(snapshot.policyCommit), 'Unknown policy commit');
+ return {
+ repository: snapshot.repository,
+ repository_id: pr.base?.repo?.id ?? null,
+ pr_number: pr.number,
+ head_sha: pr.head.sha,
+ base_sha: pr.base.sha,
+ policy_commit: snapshot.policyCommit,
+ policy_sha256: policySha,
+ diff_sha256: digest(snapshot.files.map(file => ({
+ filename: file.filename,
+ previous_filename: file.previous_filename || null,
+ status: file.status,
+ sha: file.sha,
+ additions: file.additions,
+ deletions: file.deletions,
+ patch: file.patch || '',
+ })).sort((left, right) => (left.filename < right.filename ? -1 : left.filename > right.filename ? 1 : 0))),
+ };
+}
+
+// Bodies never enter a digest or a log line: only identity and timing do.
+const stableComment = comment => ({
+ id: comment.id,
+ user: { id: comment.user?.id ?? null, login: comment.user?.login ?? null, type: comment.user?.type ?? null },
+ created_at: comment.created_at ?? null,
+ updated_at: comment.updated_at ?? null,
+});
+
+const stableThread = thread => ({
+ id: thread.id,
+ isResolved: thread.isResolved === true,
+ isOutdated: thread.isOutdated === true,
+ author: thread.author?.login ?? null,
+});
+
+const sameLogin = (user, reviewer) => Boolean(user) && normalizeLogin(user.login) === normalizeLogin(reviewer.login);
+
+function matchesReviewer(user, reviewer) {
+ return sameLogin(user, reviewer) && user.id === reviewer.user_id;
+}
+
+// GraphQL returns the thread author login for every thread but the numeric id
+// only when the viewer may read it, so a present id has to match and an absent
+// one falls back to the normalised login.
+function matchesThreadAuthor(author, reviewer) {
+ if (!sameLogin(author, reviewer)) return false;
+ if (author.id === undefined || author.id === null) return true;
+ return author.id === reviewer.user_id;
+}
+
+// Collects every marker occurrence, terminated by `-->`. A marker whose body
+// contains the terminator therefore fails to parse instead of being accepted.
+function markerPayloads(body, name) {
+ const needle = new RegExp(`', start);
+ payloads.push(end < 0 ? null : body.slice(start, end).trim());
+ }
+ return payloads;
+}
+
+const carriesMarker = body => MARKER_NAMES.some(name => markerPayloads(body, name).length > 0);
+
+const REVIEW_TEXT = {
+ review_complete: 'Reviewer evidence covers this head',
+ review_pending: 'Waiting for reviewer evidence on this head',
+ review_running: 'Reviewer review is still running on this head',
+ review_malformed: 'Newest reviewer marker is malformed or ambiguous',
+ review_stale: 'Reviewer evidence covers another commit',
+ review_findings: 'Reviewer reported unresolved findings',
+ review_findings_unknown: 'Reviewer findings state is not reported',
+ review_partial: 'Reviewer coverage is incomplete',
+ review_expired: 'Reviewer evidence exceeds the policy age limit',
+ review_threads: 'Unresolved reviewer threads remain on this head',
+};
+
+function normalizeMarker(format, payload) {
+ const review = format === 'codex-review-receipt:v1' ? payload?.review : payload?.code_review;
+ if (!review || typeof review !== 'object' || Array.isArray(review)) return null;
+ if (typeof review.commit_sha !== 'string') return null;
+ if (format === 'codex-review-receipt:v1') {
+ const unresolved = review.unresolved_findings;
+ let flagged = null;
+ if (typeof unresolved === 'boolean') flagged = unresolved;
+ else if (Number.isSafeInteger(unresolved) && unresolved >= 0) flagged = unresolved > 0;
+ const coverage = typeof review.coverage === 'string' ? review.coverage.trim().toLowerCase() : '';
+ return { commit_sha: review.commit_sha, status: 'completed', unresolved: flagged,
+ coverage: COMPLETE_COVERAGE.includes(coverage) ? 'complete' : 'partial', coverage_raw: coverage || null };
+ }
+ const unresolved = review.has_unresolved_findings;
+ return {
+ commit_sha: review.commit_sha,
+ status: typeof review.status === 'string' ? review.status : '',
+ unresolved: typeof unresolved === 'boolean' ? unresolved : null,
+ coverage: 'complete',
+ coverage_raw: null,
+ };
+}
+
+function reviewerEvidence(snapshot, policy, now) {
+ const reviewer = policy.reviewer;
+ const empty = { provider: reviewer.provider, state: 'pending', reason: 'review_pending', comment_id: null,
+ format: null, commit_sha: null, coverage: null, unresolved: null };
+ const candidates = (snapshot.comments || [])
+ .filter(comment => matchesReviewer(comment.user, reviewer) && typeof comment.body === 'string' && carriesMarker(comment.body))
+ .map(comment => {
+ assert(Number.isSafeInteger(comment.id) && comment.id > 0, 'Unknown comment metadata');
+ return { comment, stamp: Date.parse(comment.updated_at) };
+ })
+ .sort((left, right) => (right.stamp || 0) - (left.stamp || 0) || right.comment.id - left.comment.id);
+ if (!candidates.length) return empty;
+ // The newest marker-bearing comment wins before its contents are judged: a
+ // newer malformed or blocking marker must not fall back to an older clean one.
+ const { comment, stamp } = candidates[0];
+ const bound = { ...empty, comment_id: comment.id };
+ const found = [];
+ for (const name of MARKER_NAMES) {
+ for (const payload of markerPayloads(comment.body, name)) found.push({ name, payload });
+ }
+ if (found.length !== 1) return { ...bound, state: 'failure', reason: 'review_malformed' };
+ let parsed;
+ try {
+ parsed = JSON.parse(found[0].payload);
+ } catch {
+ return { ...bound, state: 'failure', reason: 'review_malformed' };
+ }
+ const evidence = normalizeMarker(found[0].name, parsed);
+ if (!evidence) return { ...bound, state: 'failure', reason: 'review_malformed' };
+ Object.assign(bound, { format: found[0].name, commit_sha: evidence.commit_sha,
+ coverage: evidence.coverage, unresolved: evidence.unresolved });
+ if (evidence.status !== 'completed') return { ...bound, state: 'pending', reason: 'review_running' };
+ if (evidence.commit_sha !== snapshot.pr.head.sha) return { ...bound, state: 'failure', reason: 'review_stale' };
+ if (evidence.unresolved === true) return { ...bound, state: 'failure', reason: 'review_findings' };
+ if (evidence.unresolved === null) return { ...bound, state: 'failure', reason: 'review_findings_unknown' };
+ if (evidence.coverage !== 'complete') return { ...bound, state: 'failure', reason: 'review_partial' };
+ const limit = policy.max_evidence_age_hours * 3600;
+ if (!Number.isFinite(stamp)) return { ...bound, state: 'failure', reason: 'review_malformed' };
+ if (now - Math.floor(stamp / 1000) > limit) return { ...bound, state: 'failure', reason: 'review_expired' };
+ return { ...bound, state: 'success', reason: 'review_complete' };
+}
+
+function unresolvedReviewerThreads(snapshot, policy) {
+ return (snapshot.threads || [])
+ .filter(thread => thread.isResolved !== true && matchesThreadAuthor(thread.author, policy.reviewer))
+ .map(thread => thread.id)
+ .sort();
+}
+
+function dependabotEvidence(snapshot, policy) {
+ const result = { detected: false, action_files: [], template_exempt: true === policy.dependabot_template_exemption };
+ if (snapshot.pr.user?.login !== DEPENDABOT) return result;
+ const commits = snapshot.commits || [];
+ // The commits API truncates at 250 entries; an incomplete history cannot prove
+ // who wrote every commit in the head.
+ if (!commits.length || commits.length >= 250) return { ...result, template_exempt: false };
+ const verified = commits.every(commit => commit.author?.login === DEPENDABOT
+ && commit.committer?.login === 'web-flow'
+ && commit.commit?.verification?.verified === true);
+ if (!verified) return { ...result, template_exempt: false };
+ const policyFile = snapshot.dependabotPolicy;
+ const actionFiles = Array.isArray(policyFile?.action_files)
+ ? policyFile.action_files.filter(path => typeof path === 'string').sort()
+ : [];
+ return { detected: true, action_files: actionFiles, template_exempt: result.template_exempt };
+}
+
+function labelEvidence(snapshot, policy) {
+ const events = (snapshot.timeline || []).filter(event => event.event === 'labeled' || event.event === 'unlabeled');
+ const present = new Set((snapshot.pr.labels || []).map(label => label.name));
+ const records = {};
+ for (const name of CLEARING_LABELS) {
+ const latest = events.filter(event => event.label?.name === name)
+ .sort((left, right) => right.id - left.id)[0];
+ if (!latest || latest.event !== 'labeled') continue;
+ if (!present.has(name)) continue;
+ // The owner label claims a human confirmation; only the configured owner id
+ // can carry that claim. Any actor may claim agent-approved, and the actor is
+ // recorded in the digest because the label API already requires write access.
+ if (name === 'owner-approved' && latest.actor?.id !== policy.owner?.user_id) continue;
+ if (!Number.isSafeInteger(latest.id) || latest.id < 1 || !Number.isSafeInteger(latest.actor?.id)) continue;
+ const expected = `${name} for PR ${snapshot.pr.number} at event ${latest.id} on this head`;
+ const bound = (snapshot.statuses || []).some(status => status.context === 'clearing-label'
+ && status.state === 'success' && status.description === expected);
+ if (!bound) continue;
+ records[name] = { event_id: latest.id, actor_id: latest.actor.id, actor_login: latest.actor.login ?? null };
+ }
+ // A human confirmation outranks an agent's when both are bound to this head.
+ const applied = records['owner-approved'] ? 'owner' : records['agent-approved'] ? 'agent' : null;
+ return { applied, records, present: [...present].filter(name => CLEARING_LABELS.includes(name)).sort() };
+}
+
+function templateVerdict(snapshot, policy, dependabot) {
+ if (dependabot?.detected && policy.dependabot_template_exemption === true) {
+ return { ok: true, exempt: true, state: 'success', description: 'Automated dependency update: template not required' };
+ }
+ const ok = completeTemplate(snapshot.pr.body);
+ return { ok, exempt: false, state: ok ? 'success' : 'failure',
+ description: ok ? 'PR template sections complete' : 'Fill in AI, changes and verification sections' };
+}
+
+const GATE_TEXT = {
+ clearing_label_required: 'needs owner-approved or agent-approved',
+ pr_template_incomplete: 'PR template incomplete',
+ ai_review_pending: 'waiting for reviewer',
+};
+
+function evaluate(snapshot, policy, policySha, now = Math.floor(Date.now() / 1000)) {
+ validatePolicy(policy);
+ assert(typeof policySha === 'string' && /^[a-f0-9]{64}$/.test(policySha), 'Unknown policy digest');
+ assert(snapshot && snapshot.complete === true, 'Incomplete snapshot');
+ const pr = snapshot.pr;
+ assert(pr && pr.state === 'open', 'Only open pull requests are evaluated');
+ assert(Number.isSafeInteger(pr.number) && pr.number > 0, 'Unknown pull request number');
+ assert(Array.isArray(snapshot.commits) && snapshot.commits.length === pr.commits
+ && snapshot.commits.length > 0 && snapshot.commits.length < 250, 'Incomplete commit list');
+ for (const field of ['comments', 'threads', 'timeline', 'statuses']) {
+ assert(Array.isArray(snapshot[field]), `Incomplete ${field}`);
+ }
+ const dependabot = dependabotEvidence(snapshot, policy);
+ const risk = classify(snapshot.files, pr.changed_files, policy, { dependabot });
+ const expected = binding(snapshot, policySha);
+ const template = templateVerdict(snapshot, policy, dependabot);
+ const labels = labelEvidence(snapshot, policy);
+ const threads = unresolvedReviewerThreads(snapshot, policy);
+ // Reviewer evidence is required at every tier: a prose-only change can still
+ // introduce a misleading claim, so R0 only lowers the label requirement.
+ let review = reviewerEvidence(snapshot, policy, now);
+ // Thread state comes from GitHub, the marker from the provider. The stricter
+ // reading wins, and a resolved-or-missing thread never proves a clean review.
+ if (threads.length && review.state !== 'failure') review = { ...review, state: 'failure', reason: 'review_threads' };
+ const blocking = threads.map(id => `thread:${id}`);
+ if (review.reason === 'review_findings') blocking.push('marker:unresolved_findings');
+ const tierOK = risk.tier !== 'R2' || labels.applied !== null;
+ const allowed = review.state === 'success' && tierOK && template.ok;
+ let reason;
+ if (review.state === 'failure') reason = review.reason;
+ else if (!tierOK) reason = 'clearing_label_required';
+ else if (!template.ok) reason = 'pr_template_incomplete';
+ else if (review.state === 'pending') reason = 'ai_review_pending';
+ else reason = review.reason;
+ const gateState = !tierOK ? 'failure'
+ : review.state === 'pending' ? 'pending'
+ : allowed ? 'success' : 'failure';
+ // Two labels, two claims: the gate names which one cleared the high-risk path
+ // so an agent-cleared merge is visible instead of passing as a human review.
+ const cleared = labels.applied === 'owner' ? 'cleared by owner-approved'
+ : labels.applied === 'agent' ? 'cleared by agent-approved, NOT confirmed by the owner' : null;
+ const gateDescription = gateState === 'success'
+ ? `${risk.tier}: ${cleared || 'review verified'}`
+ : gateState === 'pending' ? `${risk.tier}: waiting for reviewer`
+ : `${risk.tier}: ${GATE_TEXT[reason] || REVIEW_TEXT[reason] || reason}`;
+ const evidenceSha = digest({
+ binding: expected,
+ tier: risk.tier,
+ mode: policy.mode,
+ review: { format: review.format, comment_id: review.comment_id, commit_sha: review.commit_sha,
+ coverage: review.coverage, unresolved: review.unresolved, state: review.state, reason: review.reason },
+ threads: (snapshot.threads || []).map(stableThread),
+ labels: { applied: labels.applied, records: labels.records },
+ template: { ok: template.ok, exempt: template.exempt },
+ dependabot,
+ commits: (snapshot.commits || []).map(commit => ({ author: commit.author?.login ?? null,
+ committer: commit.committer?.login ?? null, verified: commit.commit?.verification?.verified === true })),
+ });
+ return {
+ policy_version: SCHEMA,
+ mode: policy.mode,
+ tier: risk.tier,
+ rules: risk.rules,
+ binding: expected,
+ review: { ...review, description: REVIEW_TEXT[review.reason] },
+ template,
+ labels,
+ dependabot,
+ blocking_findings: blocking,
+ allowed,
+ reason,
+ evidence_sha256: evidenceSha,
+ statuses: [
+ { context: 'pr-template', state: template.state, description: template.description },
+ { context: 'ai-review', state: review.state, description: REVIEW_TEXT[review.reason] },
+ { context: 'risk-gate', state: gateState, description: gateDescription },
+ ],
+ };
+}
+
+// ---------------------------------------------------------------- template ---
+
+function completeTemplate(body) {
+ // Whitespace prevents joining delimiters; an unterminated comment hides the rest.
+ const text = (body || '').replace(/|$)/g, ' ');
+ const sections = [...text.matchAll(/^#{1,6}\s+(.+)\r?\n([\s\S]*?)(?=^#{1,6}\s|$(?![\s\S]))/gm)];
+ const used = new Set();
+ const present = pattern => {
+ const index = sections.findIndex(([, heading, content], position) => !used.has(position) && pattern.test(heading)
+ && content.replace(/```[\s\S]*?```/g, '').replace(/[-*]\s*\[[ x]\]/g, '').trim().length > 3
+ && !/^\s*(?:TODO|TBD|N\/A|placeholder|\.\.\.|…|Describe the change.*|Which AI tool.*|List the checks.*)\s*$/i.test(content));
+ if (index < 0) return false;
+ used.add(index);
+ return true;
+ };
+ return present(/\bAI\b|模型|人工智能/i)
+ && present(/what changed|summary|problem|changes|变更|改动|做了什么/i)
+ && present(/how verified|verif|validat|test(?:ing| plan)?|验证|测试/i);
+}
+
+module.exports = {
+ SCHEMA, MODES, CLEARING_LABELS, MARKER_NAMES, REVIEW_TEXT,
+ sha256Hex, canonical, digest, isSha,
+ validatePolicy, pathTier, classify, completeTemplate, binding,
+ stableComment, stableThread, matchesReviewer, matchesThreadAuthor, normalizeLogin,
+ reviewerEvidence, dependabotEvidence, labelEvidence, templateVerdict, evaluate,
+};
diff --git a/pr-governance/scripts/install-gitleaks.sh b/pr-governance/scripts/install-gitleaks.sh
new file mode 100644
index 0000000..4b266e1
--- /dev/null
+++ b/pr-governance/scripts/install-gitleaks.sh
@@ -0,0 +1,30 @@
+#!/usr/bin/env bash
+set -euo pipefail
+platform="$(uname -s)-$(uname -m)"
+case "$platform" in
+ Linux-x86_64)
+ target=linux_x64
+ checksum=551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb
+ ;;
+ Darwin-x86_64)
+ target=darwin_x64
+ checksum=dfe101a4db2255fc85120ac7f3d25e4342c3c20cf749f2c20a18081af1952709
+ ;;
+ Darwin-arm64)
+ target=darwin_arm64
+ checksum=b40ab0ae55c505963e365f271a8d3846efbc170aa17f2607f13df610a9aeb6a5
+ ;;
+ *) echo "Unsupported Gitleaks platform" >&2; exit 1 ;;
+esac
+archive="$(mktemp "${RUNNER_TEMP:?}/gitleaks.XXXXXX")"
+trap 'rm -f "$archive"' EXIT
+curl --fail --silent --show-error --location --retry 2 \
+ "https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_${target}.tar.gz" \
+ --output "$archive"
+if [[ "$platform" == Linux-* ]]; then
+ echo "$checksum $archive" | sha256sum --check --status
+else
+ echo "$checksum $archive" | shasum -a 256 --check --status
+fi
+mkdir -p "$RUNNER_TEMP/gitleaks-bin"
+tar -xzf "$archive" -C "$RUNNER_TEMP/gitleaks-bin" gitleaks
diff --git a/pr-governance/scripts/main.cjs b/pr-governance/scripts/main.cjs
new file mode 100644
index 0000000..22c3e1b
--- /dev/null
+++ b/pr-governance/scripts/main.cjs
@@ -0,0 +1,81 @@
+'use strict';
+
+const gate = require('./gate.cjs');
+const { run, recordLabel } = require('./runner.cjs');
+
+const POLICY_PATH = '.github/governance/policy.json';
+
+// Account identities belong in protected configuration, not in committed
+// policy files, so the owner is resolved from repository or organization
+// variables and only falls back to the policy when none is configured.
+function configuredOwner() {
+ const raw = process.env.GOVERNANCE_OWNER_ID;
+ const login = process.env.GOVERNANCE_OWNER_LOGIN;
+ if (!raw && !login) return null;
+ const id = Number(raw);
+ if (!Number.isSafeInteger(id) || id < 1 || typeof login !== 'string' || !login) {
+ throw new Error('Incomplete owner configuration');
+ }
+ return { user_id: id, login };
+}
+
+async function defaultBranch(github, context) {
+ const known = context.payload?.repository?.default_branch
+ || context.payload?.pull_request?.base?.ref;
+ if (known) return known;
+ const { data } = await github.rest.repos.get(context.repo);
+ return data.default_branch;
+}
+
+async function loadPolicy(github, context, branch) {
+ const { data: ref } = await github.rest.repos.getBranch({ ...context.repo, branch });
+ const policyCommit = ref.commit?.sha;
+ if (!gate.isSha(policyCommit)) throw new Error('Unknown policy commit');
+ const { data: file } = await github.rest.repos.getContent({
+ ...context.repo, path: POLICY_PATH, ref: policyCommit,
+ });
+ if (file.type !== 'file' || file.encoding !== 'base64' || !file.content) {
+ throw new Error('Missing trusted policy');
+ }
+ let policy;
+ try {
+ policy = JSON.parse(Buffer.from(file.content, 'base64').toString('utf8'));
+ } catch {
+ throw new Error('Trusted policy is not valid JSON');
+ }
+ const owner = configuredOwner();
+ if (owner) policy.owner = owner;
+ gate.validatePolicy(policy);
+ // Without an owner nobody can ever satisfy owner-approved, so a missing
+ // configuration must fail the check instead of silently downgrading it.
+ if (!policy.owner) throw new Error('Owner configuration is required');
+ return { policy, policyCommit, policySha: gate.sha256Hex(gate.canonical(policy)) };
+}
+
+async function runGate({ github, context, core }) {
+ // Recording and evaluating are separate dispatch paths so the evaluate job
+ // actually runs: a label record is bound to its own delivery, while an
+ // evaluation may be superseded by any later event.
+ if (process.env.GOVERNANCE_RECORD_LABEL === 'true') {
+ return recordLabel({ github, context, core });
+ }
+ const fallback = context.payload?.repository?.default_branch
+ || context.payload?.pull_request?.base?.ref || '';
+ let branch = fallback;
+ let loaded = null;
+ try {
+ branch = await defaultBranch(github, context);
+ loaded = await loadPolicy(github, context, branch);
+ } catch (error) {
+ // A trusted policy that cannot be read must replace stale successes with
+ // blocking statuses instead of leaving the pull request looking green.
+ core?.warning?.(`Governance policy unavailable: ${error?.code || error?.name || 'Error'}`);
+ }
+ if (!loaded) {
+ return run({ github, context, core, policy: null, policySha: null, policyCommit: null,
+ defaultBranch: branch || fallback });
+ }
+ return run({ github, context, core, ...loaded, defaultBranch: branch });
+}
+
+module.exports = { runGate, loadPolicy, defaultBranch };
diff --git a/pr-governance/scripts/nudge.cjs b/pr-governance/scripts/nudge.cjs
new file mode 100644
index 0000000..af7408b
--- /dev/null
+++ b/pr-governance/scripts/nudge.cjs
@@ -0,0 +1,78 @@
+'use strict';
+
+// Posts one review request per head when the reviewer has published no receipt
+// for it. The marker is keyed to the head, so a later push gets its own request
+// without repeating this one, and a receipt already present is never overridden.
+
+const CODEX_LOGIN = /^chatgpt-codex-connector(?:\[bot\])?$/;
+const RECEIPT_MARKERS = ['codex-review-state:v1', 'codex-review-receipt:v1'];
+const MIN_HEAD_AGE_MS = 10 * 60 * 1000;
+const MAX_PULL_REQUESTS = 100;
+
+const marker = head => ``;
+
+function receiptFor(comments, head) {
+ return comments.some(comment => CODEX_LOGIN.test(comment.user?.login || '')
+ && typeof comment.body === 'string'
+ && RECEIPT_MARKERS.some(name => comment.body.includes(name))
+ && comment.body.includes(head));
+}
+
+function alreadyNudged(comments, head) {
+ const needle = marker(head);
+ return comments.some(comment => typeof comment.body === 'string' && comment.body.includes(needle));
+}
+
+async function headAge(github, repo, head) {
+ try {
+ const { data } = await github.rest.repos.getCommit({ ...repo, ref: head });
+ const date = Date.parse(data.commit?.committer?.date || data.commit?.author?.date || '');
+ return Number.isFinite(date) ? Date.now() - date : null;
+ } catch {
+ return null;
+ }
+}
+
+async function reviewRequested(github, repo, pr, core) {
+ const head = pr.head?.sha;
+ if (!head || pr.draft || pr.state !== 'open') return 'skipped';
+ const comments = await github.paginate(github.rest.issues.listComments, {
+ ...repo, issue_number: pr.number, per_page: 100,
+ });
+ if (receiptFor(comments, head)) return 'reviewed';
+ if (alreadyNudged(comments, head)) return 'nudged';
+ const age = await headAge(github, repo, head);
+ if (age === null || age < MIN_HEAD_AGE_MS) return 'too-new';
+ await github.rest.issues.createComment({
+ ...repo,
+ issue_number: pr.number,
+ body: `@codex review\n\n${marker(head)}\n\nThis head has no review receipt after 10 minutes.`,
+ });
+ core.info(`Requested a review for #${pr.number} at ${head.slice(0, 7)}`);
+ return 'requested';
+}
+
+async function run({ github, context, core }) {
+ const repo = { owner: context.repo.owner, repo: context.repo.repo };
+ let pulls;
+ if (context.payload.pull_request) {
+ pulls = [context.payload.pull_request];
+ } else {
+ pulls = await github.paginate(github.rest.pulls.list, {
+ ...repo, state: 'open', per_page: 100, sort: 'updated', direction: 'desc',
+ });
+ if (pulls.length > MAX_PULL_REQUESTS) {
+ core.info(`Evaluating the ${MAX_PULL_REQUESTS} most recently updated of ${pulls.length} open pull requests`);
+ pulls = pulls.slice(0, MAX_PULL_REQUESTS);
+ }
+ }
+
+ const counts = {};
+ for (const pr of pulls) {
+ const outcome = await reviewRequested(github, repo, pr, core);
+ counts[outcome] = (counts[outcome] || 0) + 1;
+ }
+ core.info(`Review requests: ${Object.entries(counts).map(([key, value]) => `${key}=${value}`).join(' ') || 'none'}`);
+}
+
+module.exports = { run, receiptFor, alreadyNudged, marker };
diff --git a/pr-governance/scripts/runner.cjs b/pr-governance/scripts/runner.cjs
new file mode 100644
index 0000000..b9e2444
--- /dev/null
+++ b/pr-governance/scripts/runner.cjs
@@ -0,0 +1,388 @@
+'use strict';
+
+const gate = require('./gate.cjs');
+
+// GitHub reports the status author without the bot suffix used in comment
+// payloads, so every identity comparison normalises it.
+const PUBLISHER = { id: 41898282, login: 'github-actions[bot]' };
+const STATUS_LIMIT = 1000;
+const CONTEXTS = ['pr-template', 'ai-review', 'risk-gate'];
+const POLICY_PATH = '.github/governance/policy.json';
+const DEPENDABOT_POLICY_PATH = '.github/scripts/dependabot-policy.json';
+
+const found = (message, code) => Object.assign(new Error(message), { code });
+
+async function selectTargets(github, context, defaultBranch) {
+ const repo = context.repo;
+ const event = context.payload || {};
+ const openPullRequests = () => github.paginate(github.rest.pulls.list,
+ { ...repo, state: 'open', base: defaultBranch, per_page: 100 });
+ if (context.eventName === 'push' || context.eventName === 'schedule') {
+ return (await openPullRequests()).map(pull => pull.number);
+ }
+ if (context.eventName === 'pull_request_target' || context.eventName === 'pull_request') {
+ const pull = event.pull_request;
+ if (!pull) throw new Error('Missing pull request payload');
+ // Closing a pull request can end a shared-head collision, so the remaining
+ // open pull requests are re-evaluated instead of the one that just closed.
+ if (event.action === 'closed') {
+ return (await openPullRequests()).filter(item => item.number !== pull.number).map(item => item.number);
+ }
+ if (!Number.isSafeInteger(pull.number) || pull.number < 1) throw new Error('Invalid PR number');
+ return [pull.number];
+ }
+ if (context.eventName === 'issue_comment') {
+ if (!event.issue?.pull_request) return [];
+ if (!Number.isSafeInteger(event.issue.number) || event.issue.number < 1) throw new Error('Invalid PR number');
+ return [event.issue.number];
+ }
+ if (context.eventName === 'workflow_run') {
+ // Reviews on fork and Dependabot pull requests arrive through a
+ // permissionless signal workflow; only its completion is trusted here.
+ const run = event.workflow_run;
+ if (!run) throw new Error('Missing workflow run payload');
+ const listed = Array.isArray(run.pull_requests) ? run.pull_requests : [];
+ const numbers = listed
+ .map(item => item?.number)
+ .filter(number => Number.isSafeInteger(number) && number > 0);
+ if (numbers.length) return [...new Set(numbers)];
+ const owner = run.head_repository?.owner?.login;
+ if (!owner || typeof run.head_branch !== 'string' || !run.head_branch) return [];
+ const pulls = await github.paginate(github.rest.pulls.list,
+ { ...repo, state: 'open', base: defaultBranch, head: `${owner}:${run.head_branch}`, per_page: 100 });
+ return pulls.map(pull => pull.number);
+ }
+ if (context.eventName === 'workflow_dispatch') {
+ if (context.ref !== `refs/heads/${defaultBranch}`) throw new Error('Dispatch must use the default branch');
+ const number = Number(event.inputs?.pr_number);
+ if (!Number.isSafeInteger(number) || number < 1) throw new Error('Invalid PR number');
+ return [number];
+ }
+ throw found('Unsupported event', 'UNSUPPORTED_EVENT');
+}
+
+async function readThreads(github, repo, number) {
+ const threads = [];
+ let cursor = null;
+ do {
+ // The author of a thread decides whether it blocks, so the first comment is
+ // part of the query rather than a second round trip per thread.
+ const data = await github.graphql(`query($owner:String!,$repo:String!,$number:Int!,$cursor:String) {
+ repository(owner:$owner,name:$repo) { pullRequest(number:$number) {
+ reviewThreads(first:100,after:$cursor) {
+ nodes { id isResolved isOutdated comments(first:1) { nodes { author { login } } } }
+ pageInfo { hasNextPage endCursor }
+ }
+ } }
+ }`, { ...repo, number, cursor });
+ const page = data?.repository?.pullRequest?.reviewThreads;
+ if (!page || !Array.isArray(page.nodes) || !page.pageInfo) throw new Error('Incomplete review threads');
+ for (const node of page.nodes) {
+ if (!node?.id || typeof node.isResolved !== 'boolean') throw new Error('Invalid review thread');
+ threads.push({ id: node.id, isResolved: node.isResolved, isOutdated: node.isOutdated === true,
+ author: { login: node.comments?.nodes?.[0]?.author?.login ?? null } });
+ }
+ const next = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null;
+ if (page.pageInfo.hasNextPage && (!next || next === cursor)) throw new Error('Invalid thread page');
+ cursor = next;
+ } while (cursor);
+ return threads;
+}
+
+async function readDependabotPolicy(github, repo, ref) {
+ try {
+ const { data } = await github.rest.repos.getContent({ ...repo, path: DEPENDABOT_POLICY_PATH, ref });
+ if (data?.type !== 'file' || typeof data.content !== 'string') return null;
+ const parsed = JSON.parse(Buffer.from(data.content, 'base64').toString('utf8'));
+ if (!parsed || !Array.isArray(parsed.action_files)) return null;
+ return { action_files: parsed.action_files.filter(path => typeof path === 'string').sort() };
+ } catch {
+ // A missing or unreadable dependency policy grants no exemption.
+ return null;
+ }
+}
+
+async function collect(github, context, number, policyCommit) {
+ const repo = context.repo;
+ const { data: pr } = await github.rest.pulls.get({ ...repo, pull_number: number });
+ const params = { ...repo, pull_number: number, per_page: 100 };
+ const [files, commits, comments, timeline, statuses, threads, dependabotPolicy] = await Promise.all([
+ github.paginate(github.rest.pulls.listFiles, params),
+ github.paginate(github.rest.pulls.listCommits, params),
+ github.paginate(github.rest.issues.listComments, { ...repo, issue_number: number, per_page: 100 }),
+ github.paginate(github.rest.issues.listEvents, { ...repo, issue_number: number, per_page: 100 }),
+ github.paginate(github.rest.repos.listCommitStatusesForRef, { ...repo, ref: pr.head.sha, per_page: 100 }),
+ readThreads(github, repo, number),
+ readDependabotPolicy(github, repo, policyCommit),
+ ]);
+ return {
+ repository: `${repo.owner}/${repo.repo}`,
+ policyCommit,
+ pr,
+ files,
+ commits,
+ comments,
+ timeline,
+ statuses,
+ threads,
+ dependabotPolicy,
+ complete: true,
+ };
+}
+
+function snapshotVersion(snapshot) {
+ // Only security-relevant fields: mergeability, reactions and other churn must
+ // not invalidate an evaluation, while comment edits still do through the
+ // timestamp (bodies are never part of a digest).
+ const pr = snapshot.pr;
+ return gate.digest({
+ repository: snapshot.repository,
+ policy_commit: snapshot.policyCommit,
+ pr: {
+ state: pr.state,
+ number: pr.number,
+ body: pr.body,
+ head: pr.head?.sha,
+ base: pr.base?.sha,
+ base_ref: pr.base?.ref,
+ base_repo: pr.base?.repo?.id ?? null,
+ labels: (pr.labels || []).map(label => label.name).sort(),
+ author: { id: pr.user?.id ?? null, login: pr.user?.login ?? null },
+ changed_files: pr.changed_files,
+ commits: pr.commits,
+ },
+ files: snapshot.files,
+ commits: (snapshot.commits || []).map(commit => ({
+ author: commit.author?.login ?? null,
+ committer: commit.committer?.login ?? null,
+ verified: commit.commit?.verification?.verified === true,
+ })),
+ comments: (snapshot.comments || []).map(gate.stableComment),
+ threads: (snapshot.threads || []).map(gate.stableThread),
+ timeline: (snapshot.timeline || []).map(event => ({ id: event.id, event: event.event,
+ label: event.label?.name ?? null, actor: event.actor?.id ?? null })),
+ clearing: (snapshot.statuses || []).filter(status => status.context === 'clearing-label')
+ .map(status => ({ context: status.context, state: status.state, description: status.description }))
+ .sort((left, right) => (left.description < right.description ? -1 : 1)),
+ dependabot_policy: snapshot.dependabotPolicy,
+ });
+}
+
+// The `labeled` payload is the only authenticated record of which head a
+// clearing label was added to, and the payload carries no event id. Matching the
+// addition's own timestamp against the timeline names that exact addition, so a
+// revoked and re-added label cannot reuse an earlier record.
+async function recordLabel({ github, context, core }) {
+ if (context.eventName !== 'pull_request_target' || context.payload?.action !== 'labeled') return null;
+ const label = context.payload.label?.name;
+ if (!gate.CLEARING_LABELS.includes(label)) return null;
+ const pull = context.payload.pull_request;
+ if (!pull || pull.state !== 'open' || !pull.head?.sha || !Number.isSafeInteger(pull.number)) return null;
+ const addedAt = pull.updated_at;
+ if (!addedAt) return null;
+ const events = await github.paginate(github.rest.issues.listEvents,
+ { ...context.repo, issue_number: pull.number, per_page: 100 });
+ const matches = events.filter(event => event.event === 'labeled' && event.label?.name === label
+ && event.created_at === addedAt && Number.isSafeInteger(event.actor?.id));
+ // Two additions in the same second cannot be told apart; recording either
+ // could credit the wrong head, so this delivery records nothing.
+ if (matches.length !== 1) return null;
+ const event = matches[0];
+ const description = `${label} for PR ${pull.number} at event ${event.id} on this head`;
+ const target = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
+ await github.rest.repos.createCommitStatus({ ...context.repo, sha: pull.head.sha,
+ context: 'clearing-label', state: 'success', description, target_url: target });
+ core?.info?.(`Recorded ${label} (event ${event.id}, actor ${event.actor.id}) for PR #${pull.number}`);
+ return { label, event_id: event.id, actor_id: event.actor.id, actor_login: event.actor.login ?? null, sha: pull.head.sha };
+}
+
+const collisionEntries = () => CONTEXTS.map(context => ({
+ context, state: 'error', description: 'Another open pull request shares this head',
+}));
+
+const errorEntries = description => CONTEXTS.map(context => ({ context, state: 'error', description }));
+
+// Writes the entries that are not already current, and refuses to spend the last
+// status slot of a context on anything but a blocking state.
+async function publishAll({ github, repo, head, history, entries, mode, target }) {
+ // `local` mode is a dry run: the verdict is reported, nothing is claimed.
+ if (mode === 'local') return [];
+ const planned = [];
+ let exhausted = false;
+ for (const entry of entries) {
+ const own = history.filter(status => status.context?.toLowerCase() === entry.context);
+ const description = (mode === 'report-only' ? `report-only: ${entry.description}` : entry.description).slice(0, 140);
+ const latest = own[0];
+ if (latest?.creator?.id === PUBLISHER.id && latest.creator.login === PUBLISHER.login
+ && latest.state === entry.state && latest.description === description) continue;
+ // GitHub allows 1000 statuses per commit and context. Keep the last slot for
+ // a blocking state instead of spending it on a claim.
+ if (own.length >= STATUS_LIMIT) { exhausted = true; continue; }
+ if (own.length >= STATUS_LIMIT - 1) {
+ planned.push({ context: entry.context, state: 'error', description: 'Commit status limit reached; new head required' });
+ exhausted = true;
+ continue;
+ }
+ planned.push({ context: entry.context, state: entry.state, description });
+ }
+ const written = [];
+ for (const entry of planned) {
+ const { data } = await github.rest.repos.createCommitStatus({ ...repo, sha: head,
+ context: entry.context, state: entry.state, description: entry.description, target_url: target });
+ history.unshift(data);
+ written.push(data);
+ }
+ if (exhausted) throw found('Commit status limit reached; no verdict may be published', 'STATUS_LIMIT');
+ return written;
+}
+
+async function publishUnavailable({ github, context, core, number, mode }) {
+ const { data: pull } = await github.rest.pulls.get({ ...context.repo, pull_number: number });
+ if (pull.state !== 'open' || !gate.isSha(pull.head.sha)) return;
+ const history = await github.paginate(github.rest.repos.listCommitStatusesForRef,
+ { ...context.repo, ref: pull.head.sha, per_page: 100 });
+ const target = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
+ try {
+ await publishAll({ github, repo: context.repo, head: pull.head.sha, history,
+ entries: errorEntries('Governance policy unavailable; rerun required'), mode, target });
+ } catch (error) {
+ core?.warning?.(`PR #${number}: ${error.message}`);
+ }
+}
+
+async function evaluateOne(options) {
+ const { github, context, core, policy, policySha, policyCommit, defaultBranch, now, number, mode } = options;
+ const repo = context.repo;
+ const target = `${context.serverUrl}/${repo.owner}/${repo.repo}/actions/runs/${context.runId}`;
+ const { data: initial } = await github.rest.pulls.get({ ...repo, pull_number: number });
+ if (initial.state !== 'open') return { number, skipped: true };
+ const head = initial.head.sha;
+ let history = [];
+
+ const publish = entries => publishAll({ github, repo, head, history, entries, mode, target });
+
+ const sharedHead = async () => {
+ const open = await github.paginate(github.rest.pulls.list, { ...repo, state: 'open', per_page: 100 });
+ return open.some(item => item.number !== number && item.head?.sha === head);
+ };
+
+ try {
+ // An unreadable head leaves no place to publish; a non-default base is
+ // governed by its own branch policy, so both fail closed with error
+ // statuses rather than claiming anything.
+ if (!gate.isSha(head)) throw new Error('Unknown head commit');
+ if (initial.base.ref !== defaultBranch) throw new Error('Only the default branch is governed');
+ if (await sharedHead()) {
+ const before = await collect(github, context, number, policyCommit);
+ history = before.statuses;
+ await publish(collisionEntries());
+ throw found('Another open pull request shares this head; statuses would collide', 'SHARED_HEAD');
+ }
+ const before = await collect(github, context, number, policyCommit);
+ history = before.statuses;
+ if (before.pr.head.sha !== head) throw new Error('Head changed during collection');
+ const dependabot = gate.dependabotEvidence(before, policy);
+ if (before.pr.base.sha !== policyCommit) {
+ // A stale base is a stable state, not a failed read: the evidence binds to
+ // the base it was compared against, so publish an explicit verdict instead
+ // of an opaque error until the branch is rebased.
+ const { tier } = gate.classify(before.files, before.pr.changed_files, policy, { dependabot });
+ const template = gate.templateVerdict(before, policy, dependabot);
+ await publish([
+ { context: 'pr-template', state: template.state, description: template.description },
+ { context: 'ai-review', state: 'failure', description: 'base-outdated: rebase to re-evaluate' },
+ { context: 'risk-gate', state: 'failure', description: `${tier}: base-outdated` },
+ ]);
+ // A stale base blocks that pull request through its own statuses; it must
+ // not fail the sweep, or every base push would go red while reviewed pull
+ // requests wait to rebase.
+ core?.warning?.(`PR #${number}: base ${before.pr.base.sha.slice(0, 7)} is behind ${policyCommit.slice(0, 7)}; rebase to evaluate`);
+ return { number, baseOutdated: true, allowed: false, reason: 'base-outdated', tier,
+ summary: { pr: number, tier, allowed: false, reason: 'base-outdated',
+ base_sha: before.pr.base.sha, policy_commit: policyCommit } };
+ }
+ const result = gate.evaluate(before, policy, policySha, now);
+ const after = await collect(github, context, number, policyCommit);
+ if (after.pr.head.sha !== head) throw new Error('Head changed during evaluation');
+ history = after.statuses;
+ const { data: branch } = await github.rest.repos.getBranch({ ...repo, branch: defaultBranch });
+ if (branch.commit?.sha !== policyCommit) throw new Error('Trusted base changed during evaluation');
+ if (after.pr.base.sha !== policyCommit) throw new Error('Pull request base changed during evaluation');
+ if (snapshotVersion(before) !== snapshotVersion(after)) throw new Error('Evidence changed during evaluation');
+ if (await sharedHead()) {
+ await publish(collisionEntries());
+ throw found('Another open pull request shares this head; statuses would collide', 'SHARED_HEAD');
+ }
+ // Expiry can cross during the readback; never publish a stale decision.
+ const final = gate.evaluate(after, policy, policySha, now);
+ if (gate.digest(final) !== gate.digest(result)) throw new Error('Decision changed during evaluation');
+ await publish(final.statuses);
+ return { number, allowed: final.allowed, reason: final.reason, tier: final.tier, summary: final };
+ } catch (error) {
+ // A limit breach already wrote its blocking state; retrying would only
+ // produce the same refusal.
+ if (error?.code !== 'STATUS_LIMIT') {
+ await Promise.allSettled([publish(errorEntries(error?.code === 'SHARED_HEAD'
+ ? 'Another open pull request shares this head'
+ : 'Evaluation failed; rerun required'))]);
+ }
+ throw error;
+ }
+}
+
+function writeSummary(core, entries) {
+ const summary = core?.summary;
+ if (!summary || !entries.length) return;
+ summary.addHeading('PR governance verdicts');
+ for (const entry of entries) {
+ summary.addCodeBlock(JSON.stringify(entry.summary, null, 2), 'json');
+ }
+ summary.addRaw('\nRequired checks and branch protection remain the merge constraint. '
+ + 'This record only verifies reviewer evidence and label binding.\n');
+ summary.write?.();
+}
+
+async function run({ github, context, core, policy, policySha, policyCommit, defaultBranch, now }) {
+ const stamp = Number.isFinite(now) ? now : Math.floor(Date.now() / 1000);
+ const mode = gate.MODES.includes(policy?.mode) ? policy.mode : 'enforce';
+ const targets = await selectTargets(github, context, defaultBranch);
+ const outcomes = [];
+ const failures = [];
+ for (const number of targets) {
+ if (!policy) {
+ // Policy is read from the trusted base; without it nothing can be claimed.
+ failures.push({ number, message: 'policy unavailable' });
+ await publishUnavailable({ github, context, core, number, mode });
+ continue;
+ }
+ try {
+ const outcome = await evaluateOne({ github, context, core, policy, policySha, policyCommit,
+ defaultBranch, now: stamp, number, mode });
+ if (outcome?.skipped) continue;
+ outcomes.push(outcome);
+ if (outcome.baseOutdated) continue;
+ if (!outcome.allowed) failures.push({ number, message: outcome.reason });
+ } catch (error) {
+ // Do not print response bodies: they can contain user input.
+ failures.push({ number, message: error?.code || error?.name || 'Error' });
+ }
+ }
+ writeSummary(core, outcomes);
+ if (!failures.length) return { evaluated: targets.length, failures };
+ if (mode === 'report-only') {
+ core?.warning?.(`${failures.length} pull request(s) would fail governance: ${failures.map(item => `#${item.number}`).join(', ')}`);
+ return { evaluated: targets.length, failures };
+ }
+ if (mode === 'local') {
+ core?.info?.(`${failures.length} pull request(s) would fail governance: ${failures.map(item => `#${item.number}`).join(', ')}`);
+ return { evaluated: targets.length, failures };
+ }
+ // One failure at the end of the sweep, never one per pull request.
+ core?.setFailed?.(`${failures.length} pull request(s) failed governance: ${failures.map(item => `#${item.number}`).join(', ')}`);
+ return { evaluated: targets.length, failures };
+}
+
+module.exports = {
+ PUBLISHER, STATUS_LIMIT, CONTEXTS, POLICY_PATH, DEPENDABOT_POLICY_PATH,
+ selectTargets, readThreads, readDependabotPolicy, collect, snapshotVersion, recordLabel, run,
+};
diff --git a/pr-governance/scripts/secret_scan.py b/pr-governance/scripts/secret_scan.py
new file mode 100644
index 0000000..67ff099
--- /dev/null
+++ b/pr-governance/scripts/secret_scan.py
@@ -0,0 +1,226 @@
+#!/usr/bin/env python3
+"""Redacted baseline, pull request range and artifact-path scans for Gitleaks 8.30.1."""
+from __future__ import annotations
+
+import argparse
+from collections import Counter
+import glob
+import json
+import os
+import re
+import subprocess
+import tempfile
+from pathlib import Path
+
+IDENTITY_FIELDS = ('RuleID', 'File', 'Commit', 'StartLine', 'Fingerprint')
+BASELINE_FIELDS = set(IDENTITY_FIELDS) | {'Secret', 'Match'}
+# The scanner, its rules and its pinned binary live in the governance action, so
+# the repository under judgement cannot change the rules that judge it.
+ROOT = Path(__file__).resolve().parents[1]
+CONFIG = ROOT / '.gitleaks.toml'
+COMMIT = re.compile(r'[0-9a-f]{40}')
+
+
+def identity(finding):
+ return tuple(finding[field] for field in IDENTITY_FIELDS)
+
+
+def project(finding):
+ return {**{field: finding[field] for field in IDENTITY_FIELDS},
+ 'Secret': 'REDACTED', 'Match': 'REDACTED'}
+
+
+def load_baseline(path):
+ baseline = json.loads(path.read_text())
+ if not isinstance(baseline, list):
+ raise ValueError('Invalid baseline list')
+ for finding in baseline:
+ if set(finding) != BASELINE_FIELDS or any(
+ finding[field] != 'REDACTED' for field in ('Secret', 'Match')
+ ):
+ raise ValueError('Baseline must contain only redacted identity fields')
+ if not COMMIT.fullmatch(finding['Commit']):
+ raise ValueError('Baseline must identify an immutable commit')
+ if not isinstance(finding['StartLine'], int) or finding['StartLine'] < 1:
+ raise ValueError('Invalid baseline line')
+ fingerprint = f"{finding['Commit']}:{finding['File']}:{finding['RuleID']}:{finding['StartLine']}"
+ if finding['Fingerprint'] != fingerprint:
+ raise ValueError('Baseline fingerprint does not match its identity')
+ return baseline
+
+
+def reject_source_ignores(target):
+ # Gitleaks also discovers ignore files in the scan source, independently of
+ # --gitleaks-ignore-path. Never let unreviewed source fingerprints hide findings.
+ target = Path(target)
+ if target.name == '.gitleaksignore':
+ raise RuntimeError('Source ignore files are not permitted')
+
+ if target.is_file():
+ if os.path.lexists(target.parent / '.gitleaksignore'):
+ raise RuntimeError('Source ignore files are not permitted')
+ return
+
+ def fail_walk(error):
+ raise error
+
+ for _, directories, files in os.walk(target, onerror=fail_walk):
+ if '.gitleaksignore' in directories or '.gitleaksignore' in files:
+ raise RuntimeError('Source ignore files are not permitted')
+
+
+def git(target, *arguments, capture=False):
+ # Patch output is only needed for its exit status, and historical blobs are
+ # not guaranteed to be UTF-8. Decoding them would abort the scan on any
+ # repository that ever committed a non-UTF-8 file.
+ try:
+ return subprocess.run(['git', '-C', str(target), *arguments],
+ stdout=subprocess.PIPE if capture else subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ text=True, encoding='utf-8', errors='replace', check=False)
+ except OSError:
+ raise RuntimeError('Git history is unreadable; scan blocked') from None
+
+
+def verify_history(target):
+ # Gitleaks can report success after its internal Git reader fails. Read the
+ # complete patch history first, suppressing every source-controlled diagnostic.
+ try:
+ root = git(target, 'rev-parse', '--show-toplevel', capture=True)
+ if root.returncode or Path(root.stdout.strip()).resolve() != Path(target).resolve():
+ raise RuntimeError('Git history is unreadable; scan blocked')
+ except (OSError, ValueError):
+ raise RuntimeError('Git history is unreadable; scan blocked') from None
+ for arguments in (('rev-parse', '--verify', 'HEAD^{commit}'),
+ ('log', '--all', '--full-history', '--diff-merges=first-parent', '-p', '--')):
+ if git(target, *arguments).returncode:
+ raise RuntimeError('Git history is unreadable; scan blocked')
+
+
+def verify_range(target, base, head):
+ if not COMMIT.fullmatch(base or '') or not COMMIT.fullmatch(head or ''):
+ raise RuntimeError('Git range is incomplete; scan blocked')
+ verify_history(target)
+ for revision in (base, head):
+ if git(target, 'rev-parse', '--verify', '--end-of-options',
+ f'{revision}^{{commit}}').returncode:
+ raise RuntimeError('Git range is unreadable; scan blocked')
+ if git(target, 'log', '--full-history', '-p', '--end-of-options',
+ f'{base}..{head}', '--').returncode:
+ raise RuntimeError('Git range is unreadable; scan blocked')
+
+
+def scan(binary, mode, target, report, baseline=None, config=None, log_opts=None):
+ if mode == 'git':
+ verify_history(target)
+ reject_source_ignores(target)
+ command = [binary, mode, str(target), '--config', str(config or CONFIG),
+ '--ignore-gitleaks-allow', '--redact', '--no-banner',
+ '--report-format', 'json', '--report-path', str(report)]
+ if mode == 'git':
+ # Plain `git log -p` hides merge diffs, so a credential added only while
+ # resolving a conflict would never appear in the history scan.
+ command.append('--log-opts=' + (log_opts or '--all --diff-merges=first-parent'))
+ if baseline is not None:
+ command.extend(['--baseline-path', str(baseline)])
+ result = subprocess.run(command, capture_output=True, check=False)
+ if result.returncode not in (0, 1) or not report.is_file():
+ raise RuntimeError('Gitleaks failed; no artifact may be uploaded')
+ findings = json.loads(report.read_text())
+ if not isinstance(findings, list) or bool(findings) != bool(result.returncode):
+ raise RuntimeError('Gitleaks returned an inconsistent result')
+ return findings
+
+
+def write_summary(path, findings):
+ # Only aggregate counts may leave the run: a path or commit prefix in the
+ # artifact is enough to recover the value from history.
+ path.parent.mkdir(parents=True, exist_ok=True)
+ counts = Counter(finding['RuleID'] for finding in findings)
+ path.write_text(json.dumps({'new_findings': len(findings),
+ 'by_rule': dict(sorted(counts.items()))}, indent=2) + '\n')
+
+
+def history(binary, baseline_path, summary, create=False, root=None, config=None):
+ root = Path(root or ROOT).resolve()
+ with tempfile.TemporaryDirectory(prefix='gitleaks-', dir=os.environ.get('RUNNER_TEMP')) as directory:
+ findings = scan(binary, 'git', root, Path(directory) / 'history.json', config=config)
+ if create:
+ if baseline_path.exists():
+ raise ValueError('Refusing to replace an existing history baseline')
+ baseline_path.write_text(json.dumps([project(finding) for finding in findings], indent=2) + '\n')
+ known = Counter(identity(finding) for finding in load_baseline(baseline_path))
+ remaining = known.copy()
+ new_findings = []
+ for finding in findings:
+ key = identity(finding)
+ if remaining[key] > 0:
+ remaining[key] -= 1
+ else:
+ new_findings.append(finding)
+ write_summary(summary, new_findings)
+ print(f'History scan: {len(new_findings)} new finding(s); {len(known)} baseline identities')
+ return int(bool(new_findings))
+
+
+def range_scan(binary, base, head, root=None, config=None):
+ root = Path(root or ROOT).resolve()
+ verify_range(root, base, head)
+ with tempfile.TemporaryDirectory(prefix='gitleaks-', dir=os.environ.get('RUNNER_TEMP')) as directory:
+ findings = scan(binary, 'git', root, Path(directory) / 'range.json',
+ config=config, log_opts=f'--diff-merges=first-parent {base}..{head}')
+ if findings:
+ print(f'Range scan: {len(findings)} finding(s) in added commits')
+ else:
+ print('Range scan passed: no finding in added commits')
+ return int(bool(findings))
+
+
+def artifacts(binary, patterns, config=None):
+ paths = sorted({Path(match) for pattern in patterns.splitlines() if pattern.strip()
+ for match in glob.glob(pattern.strip(), recursive=True)})
+ with tempfile.TemporaryDirectory(prefix='gitleaks-', dir=os.environ.get('RUNNER_TEMP')) as directory:
+ for index, path in enumerate(paths):
+ findings = scan(binary, 'dir', path, Path(directory) / f'{index}.json', config=config)
+ if findings:
+ print('Artifact scan blocked upload: credential finding(s); values suppressed')
+ for finding in findings:
+ print(json.dumps({'File': finding['File'], 'RuleID': finding['RuleID']}))
+ return 1
+ print(f'Artifact scan passed: {len(paths)} matching upload path(s)')
+ return 0
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('mode', choices=('history', 'range', 'artifacts'))
+ parser.add_argument('--gitleaks', default='gitleaks')
+ parser.add_argument('--root', type=Path, help='repository under judgement')
+ parser.add_argument('--config', type=Path, help='rules shipped with the governance action')
+ parser.add_argument('--base')
+ parser.add_argument('--head')
+ parser.add_argument('--baseline', type=Path)
+ parser.add_argument('--summary', type=Path)
+ parser.add_argument('--create-baseline', action='store_true')
+ arguments = parser.parse_args()
+ try:
+ if arguments.mode == 'artifacts':
+ return artifacts(arguments.gitleaks, os.environ['ARTIFACT_PATHS'], arguments.config)
+ if arguments.mode == 'range':
+ if not arguments.base or not arguments.head:
+ parser.error('range requires --base and --head')
+ return range_scan(arguments.gitleaks, arguments.base, arguments.head,
+ arguments.root, arguments.config)
+ if arguments.summary is None:
+ parser.error('history requires --summary')
+ if arguments.baseline is None:
+ parser.error('history requires --baseline')
+ return history(arguments.gitleaks, arguments.baseline, arguments.summary,
+ arguments.create_baseline, arguments.root, arguments.config)
+ except (OSError, ValueError, KeyError, TypeError, RuntimeError):
+ print('Secret scan failed safely; values and raw reports suppressed')
+ return 2
+
+
+if __name__ == '__main__':
+ raise SystemExit(main())
diff --git a/pr-governance/scripts/test-codex.cjs b/pr-governance/scripts/test-codex.cjs
new file mode 100644
index 0000000..f321115
--- /dev/null
+++ b/pr-governance/scripts/test-codex.cjs
@@ -0,0 +1,203 @@
+'use strict';
+
+// Reviewer evidence reader. Only the configured reviewer identity is read, and
+// the newest marker decides: a clean receipt never outlives a newer bad one.
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const gate = require('./gate.cjs');
+const { fixture, stateMarker, receiptMarker, rawComment, thread, HEAD, POLICY_SHA, NOW } = require('./fixtures.cjs');
+
+const evaluate = f => gate.evaluate(f.snapshot, f.policy, POLICY_SHA, NOW);
+const review = f => evaluate(f).review;
+
+test('a clean receipt-v1 approves the current head', () => {
+ const result = evaluate(receiptMarker(fixture()));
+ assert.equal(result.allowed, true);
+ assert.equal(result.review.format, 'codex-review-receipt:v1');
+ assert.equal(result.review.commit_sha, HEAD);
+ assert.equal(result.review.coverage, 'complete');
+ assert.equal(result.statuses.find(entry => entry.context === 'ai-review').state, 'success');
+});
+
+test('a clean state-v1 approves the current head', () => {
+ const result = evaluate(stateMarker(fixture()));
+ assert.equal(result.allowed, true);
+ assert.equal(result.review.format, 'codex-review-state:v1');
+ assert.equal(result.review.comment_id, 900);
+});
+
+test('a marker for another commit does not approve this head', () => {
+ const result = evaluate(receiptMarker(fixture(), { commit_sha: 'f'.repeat(40) }));
+ assert.equal(result.allowed, false);
+ assert.equal(result.review.reason, 'review_stale');
+ assert.equal(result.statuses.find(entry => entry.context === 'ai-review').state, 'failure');
+});
+
+test('an unfinished review is pending, not a failure and not a pass', () => {
+ const result = evaluate(stateMarker(fixture(), { status: 'in_progress' }));
+ assert.equal(result.allowed, false);
+ assert.equal(result.review.state, 'pending');
+ assert.equal(result.review.reason, 'review_running');
+ assert.equal(result.statuses.find(entry => entry.context === 'risk-gate').state, 'pending');
+});
+
+test('a missing marker is pending and never a clean review', () => {
+ const result = evaluate(fixture());
+ assert.equal(result.review.reason, 'review_pending');
+ assert.equal(result.review.comment_id, null);
+ assert.equal(result.statuses.find(entry => entry.context === 'ai-review').state, 'pending');
+});
+
+test('the newest marker wins when the head was reviewed twice', () => {
+ const f = receiptMarker(fixture(), { commit_sha: 'f'.repeat(40) }, { updated: -600, id: 901 });
+ receiptMarker(f, {}, { updated: -60, id: 902 });
+ assert.equal(evaluate(f).allowed, true);
+ const reverse = receiptMarker(fixture(), {}, { updated: -600, id: 901 });
+ receiptMarker(reverse, { commit_sha: 'f'.repeat(40) }, { updated: -60, id: 902 });
+ assert.equal(evaluate(reverse).allowed, false);
+});
+
+test('a newer malformed marker supersedes an older clean receipt', () => {
+ const f = receiptMarker(fixture(), {}, { updated: -600, id: 901 });
+ rawComment(f, '', { updated: -60, id: 902 });
+ const result = evaluate(f);
+ assert.equal(result.allowed, false);
+ assert.equal(result.review.reason, 'review_malformed');
+ assert.equal(result.review.comment_id, 902);
+});
+
+test('a newer marker that names no commit supersedes an older receipt', () => {
+ const f = receiptMarker(fixture(), {}, { updated: -600, id: 901 });
+ rawComment(f, '', { updated: -60, id: 902 });
+ assert.equal(evaluate(f).review.reason, 'review_malformed');
+});
+
+test('two markers in one comment are ambiguous and refused', () => {
+ const f = fixture();
+ rawComment(f, ``
+ + `\n`);
+ assert.equal(evaluate(f).review.reason, 'review_malformed');
+});
+
+test('an unterminated second marker is counted and refused', () => {
+ const f = fixture();
+ rawComment(f, ``
+ + '\n`,
+ { user: { id: 999, login: 'impostor[bot]', type: 'Bot' } });
+ assert.equal(evaluate(f).review.reason, 'review_pending');
+});
+
+test('a marker with the right login but the wrong numeric id is ignored', () => {
+ const f = fixture();
+ rawComment(f, ``,
+ { user: { id: 1, login: f.policy.reviewer.login, type: 'Bot' } });
+ assert.equal(evaluate(f).review.reason, 'review_pending');
+});
+
+test('reviewer identity is matched with and without the bot suffix', () => {
+ const f = fixture();
+ rawComment(f, ``,
+ { user: { id: f.policy.reviewer.user_id, login: f.policy.reviewer.login.replace('[bot]', ''), type: 'Bot' } });
+ assert.equal(evaluate(f).allowed, true);
+});
+
+test('unresolved findings in a receipt block and are named', () => {
+ for (const unresolved of [true, 3]) {
+ const result = evaluate(receiptMarker(fixture(), { unresolved_findings: unresolved }));
+ assert.equal(result.allowed, false);
+ assert.equal(result.review.reason, 'review_findings');
+ assert.deepEqual(result.blocking_findings, ['marker:unresolved_findings']);
+ }
+});
+
+test('a resolved receipt finding passes without a blocker', () => {
+ const result = evaluate(receiptMarker(fixture(), { unresolved_findings: 0 }));
+ assert.equal(result.allowed, true);
+ assert.deepEqual(result.blocking_findings, []);
+});
+
+test('a missing findings flag is not evidence of a clean review', () => {
+ const receipt = fixture();
+ rawComment(receipt, ``);
+ assert.equal(evaluate(receipt).review.reason, 'review_findings_unknown');
+ const state = fixture();
+ rawComment(state, ``);
+ assert.equal(evaluate(state).review.reason, 'review_findings_unknown');
+});
+
+test('partial or unknown coverage blocks the verdict', () => {
+ for (const coverage of ['partial', 'sampled', '']) {
+ const result = evaluate(receiptMarker(fixture(), { coverage }));
+ assert.equal(result.allowed, false, coverage);
+ assert.equal(result.review.reason, 'review_partial', coverage);
+ }
+});
+
+test('evidence older than the policy age limit is refused', () => {
+ const limit = 168 * 3600;
+ const stale = evaluate(receiptMarker(fixture(), {}, { updated: -limit - 1 }));
+ assert.equal(stale.review.reason, 'review_expired');
+ const fresh = evaluate(receiptMarker(fixture(), {}, { updated: -limit + 1 }));
+ assert.equal(fresh.allowed, true);
+ const future = evaluate(receiptMarker(fixture(), {}, { updated: 600 }));
+ assert.equal(future.allowed, true);
+});
+
+test('an unresolved thread blocks even when the receipt says clean', () => {
+ const f = receiptMarker(fixture());
+ thread(f, { id: 'PRRT_1' });
+ const result = evaluate(f);
+ assert.equal(result.allowed, false);
+ assert.equal(result.review.reason, 'review_threads');
+ assert.deepEqual(result.blocking_findings, ['thread:PRRT_1']);
+});
+
+test('marker findings and threads are both listed as blockers', () => {
+ const f = receiptMarker(fixture(), { unresolved_findings: 2 });
+ thread(f, { id: 'PRRT_2' });
+ assert.deepEqual(evaluate(f).blocking_findings, ['thread:PRRT_2', 'marker:unresolved_findings']);
+});
+
+test('a resolved thread and a human thread never block', () => {
+ const resolved = receiptMarker(fixture());
+ thread(resolved, { id: 'PRRT_done', resolved: true });
+ assert.equal(evaluate(resolved).allowed, true);
+ const human = receiptMarker(fixture());
+ thread(human, { id: 'PRRT_human', author: 'reviewer-person' });
+ assert.equal(evaluate(human).allowed, true);
+});
+
+test('a thread author id of another account does not block', () => {
+ const f = receiptMarker(fixture());
+ thread(f, { id: 'PRRT_fake' });
+ Object.assign(f.snapshot.threads[0].author, { id: 1 });
+ assert.equal(evaluate(f).allowed, true);
+});
+
+test('R0 prose with a stale marker stays pending', () => {
+ const f = receiptMarker(fixture({ path: 'README.md' }), { commit_sha: 'f'.repeat(40) });
+ const result = evaluate(f);
+ assert.equal(result.tier, 'R0');
+ assert.equal(result.review.reason, 'review_stale');
+ assert.equal(result.allowed, false);
+});
+
+test('an unresolved thread still blocks R0 prose', () => {
+ const f = fixture({ path: 'README.md' });
+ thread(f, { id: 'PRRT_prose' });
+ assert.equal(evaluate(f).allowed, false);
+});
+
+test('a clean receipt does not clear a high-risk change', () => {
+ const result = evaluate(receiptMarker(fixture({ path: 'runtime/engine.js' })));
+ assert.equal(result.tier, 'R2');
+ assert.equal(result.allowed, false);
+ assert.equal(result.reason, 'clearing_label_required');
+});
diff --git a/pr-governance/scripts/test-gate.cjs b/pr-governance/scripts/test-gate.cjs
new file mode 100644
index 0000000..766d269
--- /dev/null
+++ b/pr-governance/scripts/test-gate.cjs
@@ -0,0 +1,386 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { createHash } = require('node:crypto');
+const fs = require('node:fs');
+const path = require('node:path');
+const gate = require('./gate.cjs');
+const {
+ fixture, receiptMarker, stateMarker, thread, labelEvent, clearRecord,
+ HEAD, BASE, POLICY_SHA, NOW, COMPLETE_TEMPLATE,
+} = require('./fixtures.cjs');
+
+const evaluate = f => gate.evaluate(f.snapshot, f.policy, POLICY_SHA, NOW);
+
+test('sha256 matches node:crypto for ascii, unicode and empty input', () => {
+ for (const text of ['', 'abc', 'hello world', '中文 🇨🇳 测试', 'x'.repeat(4096), '\u0000\u00ff']) {
+ assert.equal(gate.sha256Hex(text), createHash('sha256').update(text, 'utf8').digest('hex'));
+ }
+});
+
+test('digest is stable under key order and exposes no undefined leaves', () => {
+ assert.equal(gate.digest({ b: 1, a: [2, { d: 3, c: 4 }] }), gate.digest({ a: [2, { c: 4, d: 3 }], b: 1 }));
+ assert.match(gate.digest({ omitted: undefined }), /^[a-f0-9]{64}$/);
+ assert.equal(gate.canonical({ z: 1, y: 'two' }), '{"y":"two","z":1}');
+});
+
+test('the shipped default policy validates', () => {
+ const shipped = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'policy.default.json'), 'utf8'));
+ assert.deepEqual(gate.validatePolicy(shipped), shipped);
+ assert.equal(shipped.version, 'pr-governance/v3');
+ assert.equal(shipped.mode, 'enforce');
+ assert.equal(shipped.reviewer.provider, 'codex');
+});
+
+test('the owner is optional and resolved from protected variables', () => {
+ const shipped = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'policy.default.json'), 'utf8'));
+ assert.equal(shipped.owner, undefined);
+ const withOwner = { ...shipped, owner: { user_id: 101, login: 'synthetic-owner' } };
+ assert.deepEqual(gate.validatePolicy(withOwner), withOwner);
+});
+
+test('the schema file lists exactly the fields the evaluator enforces', () => {
+ const schema = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'policy.schema.json'), 'utf8'));
+ assert.equal(schema.$id, 'pr-governance/v3');
+ assert.equal(schema.additionalProperties, false);
+ const declared = Object.keys(schema.properties).sort();
+ const validated = ['version', 'mode', 'reviewer', 'r0_paths', 'r2_paths',
+ 'dependabot_template_exemption', 'max_evidence_age_hours'].sort();
+ assert.deepEqual(declared, [...validated, 'owner'].sort());
+ assert.deepEqual([...schema.required].sort(), validated);
+ assert.deepEqual([...schema.properties.mode.enum].sort(), ['enforce', 'local', 'report-only']);
+ assert.deepEqual(schema.properties.reviewer.required.sort(), ['app_id', 'login', 'provider', 'user_id']);
+ assert.deepEqual(schema.properties.owner.required.sort(), ['login', 'user_id']);
+});
+
+for (const [name, mutate] of [
+ ['wrong schema version', policy => { policy.version = 'pr-governance/v2'; }],
+ ['unknown mode', policy => { policy.mode = 'advisory'; }],
+ ['missing reviewer login', policy => { delete policy.reviewer.login; }],
+ ['extra reviewer field', policy => { policy.reviewer.app_slug = 'synthetic'; }],
+ ['unknown top level field', policy => { policy.repository = 'example-org/example-repo'; }],
+ ['non bot reviewer login', policy => { policy.reviewer.login = 'some-user'; }],
+ ['string owner id', policy => { policy.owner.user_id = '101'; }],
+ ['unknown owner field', policy => { policy.owner.team = 'synthetic'; }],
+ ['empty owner login', policy => { policy.owner.login = ''; }],
+ ['unsupported provider', policy => { policy.reviewer.provider = 'coderabbit'; }],
+ ['duplicate r2 rule', policy => { policy.r2_paths.push(policy.r2_paths[0]); }],
+ ['absolute r0 rule', policy => { policy.r0_paths.push('/etc/passwd'); }],
+ ['escaping r2 rule', policy => { policy.r2_paths.push('../secrets'); }],
+ ['non boolean dependabot flag', policy => { policy.dependabot_template_exemption = 'yes'; }],
+ ['zero evidence age', policy => { policy.max_evidence_age_hours = 0; }],
+ ['excessive evidence age', policy => { policy.max_evidence_age_hours = 100000; }],
+]) test(`policy validation rejects ${name}`, () => {
+ const f = fixture();
+ mutate(f.policy);
+ assert.throws(() => evaluate(f));
+});
+
+test('protected paths are R2 and prose allowlisted paths are R0', () => {
+ const policy = fixture().policy;
+ for (const file of ['.github/workflows/ci.yml', '.github/scripts/tool.py', '.github/governance/policy.json',
+ 'config/app.json', 'settings/prod.yaml', 'deploy/release.sh', 'src/trading/engine.js', 'lib/secrets.js',
+ 'src/credentials.json', 'iam/policy.json', 'runtime/boot.js', 'strategies/momentum.py', 'Dockerfile',
+ 'infra/main.tf', 'infra/key.pem', 'package.json', 'requirements-dev.txt', 'CODEOWNERS', 'AGENTS.md',
+ 'scripts/verify-release.sh', '.env.production']) {
+ assert.equal(gate.pathTier(file, policy), 'R2', file);
+ }
+ assert.equal(gate.pathTier('README.md', policy), 'R0');
+ assert.equal(gate.pathTier('docs/prose/notes.md', policy), 'R0');
+});
+
+test('unlisted prose and ordinary code default to R1', () => {
+ const policy = fixture().policy;
+ assert.equal(gate.pathTier('docs/architecture.md', policy), 'R1');
+ assert.equal(gate.pathTier('src/view.js', policy), 'R1');
+ assert.equal(gate.pathTier('README.txt', policy), 'R1');
+});
+
+test('path traversal and absolute paths are refused', () => {
+ const policy = fixture().policy;
+ for (const file of ['../README.md', '/etc/passwd', 'a/./b', 'a//b', 'a\\b', '']) {
+ assert.throws(() => gate.pathTier(file, policy), /Unknown path/, file);
+ }
+});
+
+test('the highest tier of a change wins', () => {
+ const f = fixture({ files: [
+ { filename: 'README.md', status: 'modified', sha: 'd'.repeat(40), additions: 1, deletions: 1, patch: '@@ -1 +1 @@\n-old\n+new prose' },
+ { filename: 'src/login.js', status: 'modified', sha: 'e'.repeat(40), additions: 1, deletions: 1, patch: '@@ -1 +1 @@\n-a\n+b' },
+ ] });
+ const result = evaluate(f);
+ assert.equal(result.tier, 'R2');
+ assert.deepEqual(result.rules.map(rule => rule.tier), ['R0', 'R2']);
+});
+
+test('a rename keeps both the old and the new path in the tier', () => {
+ const f = fixture({ path: 'docs/prose/moved.md' });
+ Object.assign(f.snapshot.files[0], { status: 'renamed', previous_filename: 'iam/policy.json',
+ additions: 0, deletions: 0, patch: '' });
+ assert.equal(evaluate(f).tier, 'R2');
+});
+
+test('prose that carries code or a sensitive keyword is not low risk', () => {
+ const code = fixture({ path: 'README.md' });
+ code.snapshot.files[0].patch = '@@ -1 +1 @@\n-before\n+`npm install`';
+ assert.equal(evaluate(code).tier, 'R1');
+ const secret = fixture({ path: 'README.md' });
+ secret.snapshot.files[0].patch = '@@ -1 +1 @@\n-before\n+Rotate the deployment credential';
+ assert.equal(evaluate(secret).tier, 'R2');
+ const chinese = fixture({ path: 'README.md' });
+ chinese.snapshot.files[0].patch = '@@ -1 +1 @@\n-before\n+更新部署流程';
+ assert.equal(evaluate(chinese).tier, 'R2');
+});
+
+test('index-only prose edits still need a reviewer', () => {
+ const f = fixture({ path: 'README.md' });
+ Object.assign(f.snapshot.files[0], { status: 'added', additions: 1, deletions: 0,
+ patch: '@@ -0,0 +1 @@\n+A brand new paragraph.' });
+ assert.equal(evaluate(f).tier, 'R1');
+});
+
+for (const [name, mutate] of [
+ ['incomplete file list', s => { s.pr.changed_files = 2; }],
+ ['missing patch', s => { delete s.files[0].patch; }],
+ ['truncated patch', s => { s.files[0].additions = 2; }],
+ ['binary diff', s => { Object.assign(s.files[0], { patch: '', additions: 0, deletions: 0 }); }],
+ ['missing old rename path', s => { s.files[0].status = 'renamed'; }],
+ ['unknown file state', s => { s.files[0].status = 'unknown'; }],
+ ['duplicate file', s => { s.files.push({ ...s.files[0] }); s.pr.changed_files = 2; }],
+ ['missing file sha', s => { s.files[0].sha = ''; }],
+ ['incomplete commit coverage', s => { s.pr.commits = 2; }],
+ ['incomplete snapshot', s => { s.complete = false; }],
+ ['closed pull request', s => { s.pr.state = 'closed'; }],
+ ['unknown head', s => { s.pr.head.sha = ''; }],
+ ['missing evidence lists', s => { delete s.threads; }],
+]) test(`${name} fails closed`, () => {
+ const f = fixture();
+ mutate(f.snapshot);
+ assert.throws(() => evaluate(f));
+});
+
+test('the file API cap is refused even when the count matches', () => {
+ const f = fixture({ path: 'src/view.js' });
+ f.snapshot.files = Array.from({ length: 3000 }, (_, index) => ({
+ ...f.snapshot.files[0], filename: `src/${index}.js`,
+ }));
+ f.snapshot.pr.changed_files = 3000;
+ assert.throws(() => evaluate(f), /Incomplete file list/);
+});
+
+test('an unknown policy digest is refused', () => {
+ const f = fixture();
+ assert.throws(() => gate.evaluate(f.snapshot, f.policy, 'not-a-digest', NOW));
+});
+
+test('a clean receipt passes R1 and publishes three success statuses', () => {
+ const f = receiptMarker(fixture());
+ const result = evaluate(f);
+ assert.equal(result.allowed, true);
+ assert.equal(result.reason, 'review_complete');
+ assert.equal(result.tier, 'R1');
+ assert.deepEqual(result.statuses.map(entry => [entry.context, entry.state]), [
+ ['pr-template', 'success'], ['ai-review', 'success'], ['risk-gate', 'success'],
+ ]);
+ assert.equal(result.binding.head_sha, HEAD);
+ assert.equal(result.binding.base_sha, BASE);
+ assert.equal(result.binding.policy_sha256, POLICY_SHA);
+ assert.match(result.evidence_sha256, /^[a-f0-9]{64}$/);
+});
+
+test('R0 prose still needs the current-head reviewer receipt', () => {
+ const missing = evaluate(fixture({ path: 'README.md' }));
+ assert.equal(missing.tier, 'R0');
+ assert.equal(missing.allowed, false);
+ assert.equal(missing.reason, 'ai_review_pending');
+ const reviewed = evaluate(receiptMarker(fixture({ path: 'README.md' })));
+ assert.equal(reviewed.allowed, true);
+ assert.equal(reviewed.review.state, 'success');
+ assert.deepEqual(reviewed.statuses.map(entry => entry.context), ['pr-template', 'ai-review', 'risk-gate']);
+});
+
+test('R1 without any reviewer marker stays pending', () => {
+ const result = evaluate(fixture());
+ assert.equal(result.allowed, false);
+ assert.equal(result.review.state, 'pending');
+ assert.equal(result.reason, 'ai_review_pending');
+ assert.deepEqual(result.statuses.map(entry => [entry.context, entry.state]), [
+ ['pr-template', 'success'], ['ai-review', 'pending'], ['risk-gate', 'pending'],
+ ]);
+});
+
+test('high risk needs a clearing label even when the review is clean', () => {
+ const f = receiptMarker(fixture({ path: 'config/app.json' }));
+ const result = evaluate(f);
+ assert.equal(result.tier, 'R2');
+ assert.equal(result.allowed, false);
+ assert.equal(result.reason, 'clearing_label_required');
+ assert.equal(result.statuses.at(-1).state, 'failure');
+});
+
+test('a bound owner label clears high risk and names the human approval', () => {
+ const f = receiptMarker(fixture({ path: 'config/app.json' }));
+ const record = labelEvent(f);
+ clearRecord(f, record);
+ const result = evaluate(f);
+ assert.equal(result.allowed, true);
+ assert.equal(result.labels.applied, 'owner');
+ assert.match(result.statuses.at(-1).description, /cleared by owner-approved/);
+});
+
+test('a bound agent label clears high risk and names the agent approval', () => {
+ const f = receiptMarker(fixture({ path: 'config/app.json' }));
+ const record = labelEvent(f, { name: 'agent-approved', actor: { id: 999, login: 'agent' } });
+ clearRecord(f, record);
+ const result = evaluate(f);
+ assert.equal(result.allowed, true);
+ assert.equal(result.labels.applied, 'agent');
+ assert.equal(result.labels.records['agent-approved'].actor_id, 999);
+});
+
+test('an incomplete template blocks the verdict', () => {
+ const f = receiptMarker(fixture());
+ f.snapshot.pr.body = '## What changed\nSomething.\n## How verified\nTests.';
+ const result = evaluate(f);
+ assert.equal(result.allowed, false);
+ assert.equal(result.reason, 'pr_template_incomplete');
+ assert.deepEqual(result.statuses.map(entry => [entry.context, entry.state]), [
+ ['pr-template', 'failure'], ['ai-review', 'success'], ['risk-gate', 'failure'],
+ ]);
+});
+
+test('an unresolved reviewer thread blocks a clean receipt', () => {
+ const f = receiptMarker(fixture());
+ thread(f, { id: 'PRRT_kwDO_block' });
+ const result = evaluate(f);
+ assert.equal(result.allowed, false);
+ assert.equal(result.review.state, 'failure');
+ assert.equal(result.review.reason, 'review_threads');
+ assert.deepEqual(result.blocking_findings, ['thread:PRRT_kwDO_block']);
+ assert.equal(result.statuses.find(entry => entry.context === 'ai-review').state, 'failure');
+});
+
+test('stable thread ids are part of the evidence digest', () => {
+ const first = fixture();
+ thread(first, { id: 'PRRT_one', resolved: true });
+ const second = fixture();
+ thread(second, { id: 'PRRT_two', resolved: true });
+ assert.notEqual(evaluate(first).evidence_sha256, evaluate(second).evidence_sha256);
+ const resolved = fixture();
+ thread(resolved, { id: 'PRRT_one', resolved: true });
+ assert.equal(evaluate(first).evidence_sha256, evaluate(resolved).evidence_sha256);
+});
+
+test('comment prose never enters the evidence digest', () => {
+ const first = fixture();
+ receiptMarker(first, {}, { updated: -60 });
+ const second = fixture();
+ receiptMarker(second, {}, { updated: -60 });
+ second.snapshot.comments[0].body = '\n\nCompletely different prose that must not be hashed.\n';
+ assert.equal(evaluate(first).evidence_sha256, evaluate(second).evidence_sha256);
+});
+
+test('template detection accepts English and Chinese headings', () => {
+ assert.equal(gate.completeTemplate(COMPLETE_TEMPLATE), true);
+ assert.equal(gate.completeTemplate('## AI 使用情况\n使用了智能体。\n## 变更\n修复门禁。\n## 测试\n离线测试通过。'), true);
+ assert.equal(gate.completeTemplate('## 模型\nCodex。\n## 这个 PR 做了什么\n修复门禁。\n## 怎么验证的\n离线测试通过。'), true);
+ assert.equal(gate.completeTemplate('## AI\nCodex.\n## Summary\nGate fix.\n## Verification\nOffline tests passed.'), true);
+ assert.equal(gate.completeTemplate('## AI\nCodex.\n## What changed\nTODO\n## How verified\nOffline tests passed.'), false);
+ assert.equal(gate.completeTemplate('## AI\nCodex.\n## What changed\nplaceholder\n## How verified\nTBD'), false);
+ assert.equal(gate.completeTemplate('## AI\nCodex.\n## What changed\nValid detail.\n## How verified\n\n '), false);
+ assert.equal(gate.completeTemplate('## Main changes and tests\nEverything is complete.'), false);
+ assert.equal(gate.completeTemplate(''), false);
+});
+
+test('template comments cannot manufacture or hide headings', () => {
+ assert.equal(gate.completeTemplate('## AI\nCodex.\nhat changed\nDetail.\n## How verified\nDetail.'), false);
+ assert.equal(gate.completeTemplate('## AI\nCodex.\n\n## How verified\nDetail.'), false);
+});
+
+test('dependabot detection requires bot author, web-flow committer and a verified commit', () => {
+ const commits = [{ author: { login: 'dependabot[bot]' }, committer: { login: 'web-flow' },
+ commit: { verification: { verified: true } } }];
+ const identity = login => ({ id: 49699333, login, type: 'Bot' });
+ const build = mutate => {
+ const f = fixture({ commits, dependabotPolicy: { action_files: ['.github/workflows/ci.yml'] } });
+ f.snapshot.pr.user = identity('dependabot[bot]');
+ mutate?.(f.snapshot);
+ return f;
+ };
+ assert.equal(gate.dependabotEvidence(build().snapshot, build().policy).detected, true);
+ for (const mutate of [
+ snapshot => { snapshot.commits[0].committer.login = 'author'; },
+ snapshot => { snapshot.commits[0].commit.verification.verified = false; },
+ snapshot => { snapshot.commits[0].author.login = 'author'; },
+ snapshot => { snapshot.pr.user = { id: 7, login: 'author' }; },
+ snapshot => { snapshot.commits = []; snapshot.pr.commits = 0; },
+ ]) {
+ const f = build(mutate);
+ assert.equal(gate.dependabotEvidence(f.snapshot, f.policy).detected, false);
+ }
+});
+
+test('an allowlisted action bump is not high risk, a different workflow still is', () => {
+ const bump = path => ({ filename: path, status: 'modified', sha: 'd'.repeat(40), additions: 1, deletions: 1,
+ patch: '@@ -1 +1 @@\n- - uses: actions/checkout@v5\n+ - uses: actions/checkout@v6' });
+ const commits = [{ author: { login: 'dependabot[bot]' }, committer: { login: 'web-flow' },
+ commit: { verification: { verified: true } } }];
+ const build = files => {
+ const f = fixture({ files, commits });
+ f.snapshot.pr.user = { id: 49699333, login: 'dependabot[bot]', type: 'Bot' };
+ f.snapshot.dependabotPolicy = { action_files: ['.github/workflows/ci.yml'] };
+ return f;
+ };
+ const allowed = build([bump('.github/workflows/ci.yml')]);
+ assert.equal(evaluate(allowed).tier, 'R1');
+ const other = build([bump('.github/workflows/release.yml')]);
+ assert.equal(evaluate(other).tier, 'R2');
+ const swapped = build([{ filename: '.github/workflows/ci.yml', status: 'modified', sha: 'd'.repeat(40),
+ additions: 1, deletions: 1, patch: '@@ -1 +1 @@\n- - uses: actions/checkout@v5\n+ - uses: evil/action@v6' }]);
+ assert.equal(evaluate(swapped).tier, 'R2');
+ const extra = build([{ filename: '.github/workflows/ci.yml', status: 'modified', sha: 'd'.repeat(40),
+ additions: 2, deletions: 1,
+ patch: '@@ -1,2 +1,2 @@\n- - uses: actions/checkout@v5\n+ - uses: actions/checkout@v6\n+ if: false' }]);
+ assert.equal(evaluate(extra).tier, 'R2');
+ const human = build([bump('.github/workflows/ci.yml')]);
+ human.snapshot.pr.user = { id: 7, login: 'author' };
+ assert.equal(evaluate(human).tier, 'R2');
+});
+
+test('Dependabot template exemption follows the policy flag', () => {
+ const commits = [{ author: { login: 'dependabot[bot]' }, committer: { login: 'web-flow' },
+ commit: { verification: { verified: true } } }];
+ const build = flag => {
+ const f = fixture({ commits, body: 'Automated dependency bump.', policy: policy => {
+ policy.dependabot_template_exemption = flag;
+ } });
+ f.snapshot.pr.user = { id: 49699333, login: 'dependabot[bot]', type: 'Bot' };
+ return receiptMarker(f);
+ };
+ assert.equal(evaluate(build(true)).template.exempt, true);
+ assert.equal(evaluate(build(true)).allowed, true);
+ assert.equal(evaluate(build(false)).template.ok, false);
+ assert.equal(evaluate(build(false)).allowed, false);
+});
+
+test('a pull request author never influences the verdict outside Dependabot detection', () => {
+ for (const login of ['author', 'agent', 'someone-else']) {
+ const f = receiptMarker(fixture({ path: 'config/app.json' }));
+ f.snapshot.pr.user = { id: 11, login };
+ assert.equal(evaluate(f).allowed, false);
+ assert.equal(evaluate(f).reason, 'clearing_label_required');
+ }
+});
+
+test('the mode is carried into the result without changing the verdict', () => {
+ for (const mode of ['enforce', 'local', 'report-only']) {
+ const f = receiptMarker(fixture(), {}, {});
+ f.policy.mode = mode;
+ assert.equal(evaluate(f).mode, mode);
+ assert.equal(evaluate(f).allowed, true);
+ }
+});
diff --git a/pr-governance/scripts/test-labels.cjs b/pr-governance/scripts/test-labels.cjs
new file mode 100644
index 0000000..a41536e
--- /dev/null
+++ b/pr-governance/scripts/test-labels.cjs
@@ -0,0 +1,267 @@
+'use strict';
+
+// Label evidence. A label lives on the pull request, not on a commit, so the
+// only label that counts is one whose authenticated timeline event is still the
+// newest event for that label and whose head-bound record says so.
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const gate = require('./gate.cjs');
+const runner = require('./runner.cjs');
+const { fixture, receiptMarker, labelEvent, clearRecord, HEAD, POLICY_SHA, NOW } = require('./fixtures.cjs');
+
+const evaluate = f => gate.evaluate(f.snapshot, f.policy, POLICY_SHA, NOW);
+const highRisk = () => receiptMarker(fixture({ path: 'config/settings.json' }));
+
+test('an owner label added by the owner and bound to the head clears high risk', () => {
+ const f = highRisk();
+ const record = labelEvent(f);
+ clearRecord(f, record);
+ const result = evaluate(f);
+ assert.equal(result.allowed, true);
+ assert.equal(result.labels.applied, 'owner');
+ assert.deepEqual(result.labels.records['owner-approved'], { event_id: record.id,
+ actor_id: f.policy.owner.user_id, actor_login: f.policy.owner.login });
+});
+
+test('an owner-approved label from another actor never counts', () => {
+ const f = highRisk();
+ const record = labelEvent(f, { actor: { id: 5, login: 'author' } });
+ clearRecord(f, record);
+ const result = evaluate(f);
+ assert.equal(result.allowed, false);
+ assert.equal(result.labels.applied, null);
+ assert.equal(result.reason, 'clearing_label_required');
+});
+
+test('an owner login with the wrong numeric id never counts', () => {
+ const f = highRisk();
+ const record = labelEvent(f, { actor: { id: 5, login: f.policy.owner.login } });
+ clearRecord(f, record);
+ assert.equal(evaluate(f).labels.applied, null);
+});
+
+test('an agent label counts from any actor and records who added it', () => {
+ for (const actor of [{ id: 5, login: 'author' }, { id: 4242, login: 'some-agent[bot]' }]) {
+ const f = highRisk();
+ const record = labelEvent(f, { name: 'agent-approved', actor });
+ clearRecord(f, record);
+ const result = evaluate(f);
+ assert.equal(result.allowed, true);
+ assert.equal(result.labels.applied, 'agent');
+ assert.deepEqual(result.labels.records['agent-approved'], { event_id: record.id,
+ actor_id: actor.id, actor_login: actor.login });
+ assert.match(result.statuses.at(-1).description, /cleared by agent-approved, NOT confirmed by the owner/);
+ }
+});
+
+test('a label without a head-bound record never clears high risk', () => {
+ const f = highRisk();
+ labelEvent(f);
+ const result = evaluate(f);
+ assert.equal(result.allowed, false);
+ assert.equal(result.labels.applied, null);
+});
+
+test('a record for a different event does not revive a re-added label', () => {
+ const f = highRisk();
+ const first = labelEvent(f, { id: 5000 });
+ clearRecord(f, first);
+ labelEvent(f, { id: 5001, event: 'unlabeled' });
+ const second = labelEvent(f, { id: 5002 });
+ assert.equal(evaluate(f).labels.applied, null);
+ clearRecord(f, second);
+ assert.equal(evaluate(f).labels.applied, 'owner');
+});
+
+test('a revoked label stays revoked even with its old record present', () => {
+ const f = highRisk();
+ const record = labelEvent(f);
+ clearRecord(f, record);
+ labelEvent(f, { id: 6000, event: 'unlabeled' });
+ assert.equal(evaluate(f).labels.applied, null);
+ assert.equal(evaluate(f).allowed, false);
+});
+
+test('a label event that is not the newest for that label is ignored', () => {
+ const f = highRisk();
+ const stale = labelEvent(f, { id: 4999, name: 'agent-approved', actor: { id: 5, login: 'author' } });
+ clearRecord(f, stale);
+ labelEvent(f, { id: 5001, name: 'agent-approved', event: 'unlabeled' });
+ assert.equal(evaluate(f).labels.applied, null);
+});
+
+test('a record that names another pull request does not bind', () => {
+ const f = highRisk();
+ const record = labelEvent(f);
+ clearRecord(f, record, { description: `${record.name} for PR 7 at event ${record.id} on this head` });
+ assert.equal(evaluate(f).labels.applied, null);
+});
+
+test('a failing clearing-label status does not bind', () => {
+ const f = highRisk();
+ const record = labelEvent(f);
+ clearRecord(f, record);
+ f.snapshot.statuses[0].state = 'failure';
+ assert.equal(evaluate(f).labels.applied, null);
+});
+
+test('a label left on the pull request without any timeline event does not bind', () => {
+ const f = highRisk();
+ f.snapshot.pr.labels = [{ name: 'owner-approved' }];
+ f.snapshot.statuses.push({ context: 'clearing-label', state: 'success',
+ description: `owner-approved for PR 42 at event 1 on this head` });
+ assert.equal(evaluate(f).labels.applied, null);
+});
+
+test('a bound owner label outranks a bound agent label on the same head', () => {
+ const f = highRisk();
+ const owner = labelEvent(f, { id: 5000, name: 'owner-approved' });
+ clearRecord(f, owner);
+ const agent = labelEvent(f, { id: 5001, name: 'agent-approved', actor: { id: 5, login: 'author' } });
+ clearRecord(f, agent);
+ assert.equal(evaluate(f).labels.applied, 'owner');
+});
+
+test('a bound agent label still clears high risk when the owner label is unbound', () => {
+ const f = highRisk();
+ labelEvent(f, { id: 5000, name: 'owner-approved' });
+ const agent = labelEvent(f, { id: 5001, name: 'agent-approved', actor: { id: 5, login: 'author' } });
+ clearRecord(f, agent);
+ const result = evaluate(f);
+ assert.equal(result.allowed, true);
+ assert.equal(result.labels.applied, 'agent');
+});
+
+test('no label bypasses the review requirement', () => {
+ const f = fixture({ path: 'config/settings.json' });
+ const record = labelEvent(f);
+ clearRecord(f, record);
+ const result = evaluate(f);
+ assert.equal(result.allowed, false);
+ assert.equal(result.reason, 'ai_review_pending');
+ assert.equal(result.statuses.find(entry => entry.context === 'ai-review').state, 'pending');
+ assert.equal(result.statuses.find(entry => entry.context === 'risk-gate').state, 'pending');
+});
+
+test('no label bypasses an unresolved reviewer thread', () => {
+ const f = highRisk();
+ const record = labelEvent(f);
+ clearRecord(f, record);
+ f.snapshot.threads.push({ id: 'PRRT_open', isResolved: false, isOutdated: false,
+ author: { login: f.policy.reviewer.login } });
+ const result = evaluate(f);
+ assert.equal(result.allowed, false);
+ assert.equal(result.review.reason, 'review_threads');
+});
+
+test('no label bypasses the pull request template', () => {
+ const f = highRisk();
+ f.snapshot.pr.body = 'Nothing useful here.';
+ const record = labelEvent(f);
+ clearRecord(f, record);
+ const result = evaluate(f);
+ assert.equal(result.allowed, false);
+ assert.equal(result.reason, 'pr_template_incomplete');
+ assert.equal(result.statuses.find(entry => entry.context === 'risk-gate').state, 'failure');
+});
+
+test('low risk needs no label, so an unbound label changes nothing', () => {
+ const withLabel = receiptMarker(fixture({ path: 'src/view.js' }));
+ labelEvent(withLabel);
+ const withoutLabel = receiptMarker(fixture({ path: 'src/view.js' }));
+ assert.equal(evaluate(withLabel).allowed, true);
+ assert.equal(evaluate(withLabel).evidence_sha256, evaluate(withoutLabel).evidence_sha256);
+});
+
+// ------------------------------------------------------------ record step ---
+
+function recordContext(mutate = () => {}) {
+ const context = {
+ eventName: 'pull_request_target',
+ repo: { owner: 'example-org', repo: 'example-repo' },
+ payload: {
+ action: 'labeled',
+ label: { name: 'owner-approved' },
+ pull_request: { number: 42, state: 'open', head: { sha: HEAD }, updated_at: '2026-01-01T00:00:00Z' },
+ },
+ serverUrl: 'https://github.example',
+ runId: 7,
+ };
+ mutate(context);
+ return context;
+}
+
+function recordMock(timeline) {
+ const statuses = [];
+ const github = {
+ paginate: async () => timeline,
+ rest: {
+ issues: { listEvents: 'events' },
+ repos: { createCommitStatus: async input => { statuses.push(input); return { data: input }; } },
+ },
+ };
+ const infos = [];
+ return { github, core: { info: message => infos.push(message) }, statuses, infos };
+}
+
+const event = (overrides = {}) => ({ id: 5000, event: 'labeled', label: { name: 'owner-approved' },
+ actor: { id: 20649888, login: 'owner' }, created_at: '2026-01-01T00:00:00Z', ...overrides });
+
+test('the record step writes the exact head-bound description', async () => {
+ const m = recordMock([event()]);
+ const record = await runner.recordLabel({ github: m.github, context: recordContext(), core: m.core });
+ assert.equal(record.event_id, 5000);
+ assert.deepEqual(m.statuses, [{
+ owner: 'example-org',
+ repo: 'example-repo',
+ sha: HEAD,
+ context: 'clearing-label',
+ state: 'success',
+ description: 'owner-approved for PR 42 at event 5000 on this head',
+ target_url: 'https://github.example/example-org/example-repo/actions/runs/7',
+ }]);
+ assert.match(m.infos[0], /actor 20649888/);
+});
+
+test('the record step accepts an agent label and records its actor', async () => {
+ const context = recordContext(context => { context.payload.label = { name: 'agent-approved' }; });
+ const m = recordMock([event({ label: { name: 'agent-approved' }, actor: { id: 77, login: 'agent' } })]);
+ const record = await runner.recordLabel({ github: m.github, context, core: m.core });
+ assert.equal(record.actor_id, 77);
+ assert.equal(m.statuses[0].description, 'agent-approved for PR 42 at event 5000 on this head');
+});
+
+test('the record step ignores everything that is not a clearing label addition', async () => {
+ const cases = [
+ recordContext(context => { context.payload.label = { name: 'documentation' }; }),
+ recordContext(context => { context.payload.action = 'unlabeled'; }),
+ recordContext(context => { context.eventName = 'pull_request'; }),
+ recordContext(context => { context.payload.pull_request.state = 'closed'; }),
+ recordContext(context => { context.payload.pull_request.head = {}; }),
+ recordContext(context => { context.payload.pull_request.updated_at = null; }),
+ ];
+ for (const context of cases) {
+ const m = recordMock([event()]);
+ assert.equal(await runner.recordLabel({ github: m.github, context, core: m.core }), null);
+ assert.deepEqual(m.statuses, []);
+ }
+});
+
+test('an ambiguous same-second addition records nothing', async () => {
+ const m = recordMock([event(), event({ id: 5001 })]);
+ assert.equal(await runner.recordLabel({ github: m.github, context: recordContext(), core: m.core }), null);
+ assert.deepEqual(m.statuses, []);
+});
+
+test('an addition whose timestamp does not match any event records nothing', async () => {
+ const m = recordMock([event({ created_at: '2026-01-01T00:00:09Z' })]);
+ assert.equal(await runner.recordLabel({ github: m.github, context: recordContext(), core: m.core }), null);
+ assert.deepEqual(m.statuses, []);
+});
+
+test('a label event without an actor cannot be recorded', async () => {
+ const m = recordMock([event({ actor: {} })]);
+ assert.equal(await runner.recordLabel({ github: m.github, context: recordContext(), core: m.core }), null);
+ assert.deepEqual(m.statuses, []);
+});
diff --git a/pr-governance/scripts/test-main.cjs b/pr-governance/scripts/test-main.cjs
new file mode 100644
index 0000000..ef16177
--- /dev/null
+++ b/pr-governance/scripts/test-main.cjs
@@ -0,0 +1,137 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const main = require('./main.cjs');
+
+const POLICY = {
+ 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,
+};
+
+const githubFor = policy => ({
+ rest: {
+ repos: {
+ get: async () => ({ data: { default_branch: 'main' } }),
+ getBranch: async () => ({ data: { commit: { sha: 'a'.repeat(40) } } }),
+ getContent: async () => ({ data: { type: 'file', encoding: 'base64',
+ content: Buffer.from(JSON.stringify(policy)).toString('base64') } }),
+ },
+ },
+});
+
+const context = { repo: { owner: 'example-org', repo: 'example-repo' }, payload: {} };
+
+async function load(policy, environment) {
+ const saved = { ...process.env };
+ Object.assign(process.env, environment);
+ try {
+ return await main.loadPolicy(githubFor(policy), context, 'main');
+ } finally {
+ delete process.env.GOVERNANCE_OWNER_ID;
+ delete process.env.GOVERNANCE_OWNER_LOGIN;
+ Object.assign(process.env, saved);
+ }
+}
+
+test('owner configuration replaces a committed owner', async () => {
+ const { policy } = await load({ ...POLICY, owner: { user_id: 1, login: 'stale' } },
+ { GOVERNANCE_OWNER_ID: '42', GOVERNANCE_OWNER_LOGIN: 'owner' });
+ assert.deepEqual(policy.owner, { user_id: 42, login: 'owner' });
+});
+
+test('a policy without owner is valid when the variables are set', async () => {
+ const { policy, policySha } = await load(POLICY,
+ { GOVERNANCE_OWNER_ID: '42', GOVERNANCE_OWNER_LOGIN: 'owner' });
+ assert.deepEqual(policy.owner, { user_id: 42, login: 'owner' });
+ assert.match(policySha, /^[0-9a-f]{64}$/);
+});
+
+test('a policy without owner and without variables fails closed', async () => {
+ await assert.rejects(load(POLICY, {}));
+});
+
+test('an incomplete owner configuration fails closed', async () => {
+ await assert.rejects(load(POLICY, { GOVERNANCE_OWNER_ID: '42' }), /owner configuration/);
+ await assert.rejects(load(POLICY,
+ { GOVERNANCE_OWNER_ID: '0', GOVERNANCE_OWNER_LOGIN: 'owner' }), /owner configuration/);
+});
+
+async function withEnvironment(environment, action) {
+ const saved = { ...process.env };
+ delete process.env.GOVERNANCE_RECORD_LABEL;
+ Object.assign(process.env, environment);
+ try {
+ return await action();
+ } finally {
+ delete process.env.GOVERNANCE_RECORD_LABEL;
+ Object.assign(process.env, saved);
+ }
+}
+
+test('recording a label is a separate dispatch path', async () => {
+ const written = [];
+ const labeled = {
+ action: 'labeled',
+ label: { name: 'agent-approved' },
+ pull_request: { number: 7, state: 'open', updated_at: '2026-01-01T00:00:00Z',
+ head: { sha: 'b'.repeat(40) }, base: { ref: 'main' } },
+ };
+ const github = {
+ paginate: async () => [{ id: 11, event: 'labeled', created_at: '2026-01-01T00:00:00Z',
+ label: { name: 'agent-approved' }, actor: { id: 42, login: 'agent' } }],
+ rest: {
+ issues: { listEvents: async () => ({ data: [] }) },
+ repos: {
+ // Evaluation would need this; the record path must never get here.
+ get: async () => { throw new Error('recording must not read the repository'); },
+ createCommitStatus: async params => { written.push(params); return { data: params }; },
+ },
+ },
+ };
+ const result = await withEnvironment({ GOVERNANCE_RECORD_LABEL: 'true' }, () => main.runGate({
+ github, core: { info() {} },
+ context: { eventName: 'pull_request_target', repo: { owner: 'example-org', repo: 'example-repo' },
+ payload: labeled, serverUrl: 'https://github.example', runId: 9 },
+ }));
+ assert.equal(result.event_id, 11);
+ assert.deepEqual(written.map(item => [item.context, item.state]), [['clearing-label', 'success']]);
+});
+
+test('an unreadable policy publishes blocking statuses instead of stale successes', async () => {
+ const written = [];
+ const failed = [];
+ const github = {
+ paginate: async (method, params) => (await method(params)).data,
+ rest: {
+ repos: {
+ get: async () => { throw new Error('network'); },
+ listCommitStatusesForRef: async () => ({ data: [] }),
+ createCommitStatus: async params => { written.push(params); return { data: params }; },
+ },
+ pulls: {
+ get: async () => ({ data: { number: 7, state: 'open', head: { sha: 'b'.repeat(40) },
+ base: { ref: 'main' } } }),
+ list: async () => ({ data: [] }),
+ },
+ },
+ };
+ const result = await withEnvironment({ GOVERNANCE_RECORD_LABEL: 'false' }, () => main.runGate({
+ github, core: { warning() {}, setFailed: message => failed.push(message) },
+ context: { eventName: 'pull_request_target', repo: { owner: 'example-org', repo: 'example-repo' },
+ payload: { action: 'labeled', label: { name: 'agent-approved' },
+ pull_request: { number: 7, state: 'open', head: { sha: 'b'.repeat(40) }, base: { ref: 'main' } } },
+ serverUrl: 'https://github.example', runId: 9 },
+ }));
+ assert.deepEqual(result.failures, [{ number: 7, message: 'policy unavailable' }]);
+ assert.deepEqual(written.map(item => [item.context, item.state]),
+ [['pr-template', 'error'], ['ai-review', 'error'], ['risk-gate', 'error']]);
+ assert.equal(failed.length, 1);
+});
+
diff --git a/pr-governance/scripts/test-nudge.cjs b/pr-governance/scripts/test-nudge.cjs
new file mode 100644
index 0000000..cc1fb17
--- /dev/null
+++ b/pr-governance/scripts/test-nudge.cjs
@@ -0,0 +1,77 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { run, marker } = require('./nudge.cjs');
+
+const HEAD = 'a'.repeat(40);
+const OLD = new Date(Date.now() - 60 * 60 * 1000).toISOString();
+const core = { info() {} };
+
+function harness({ comments = [], headDate = OLD, draft = false, state = 'open' } = {}) {
+ const posted = [];
+ const github = {
+ paginate: async (fn, params) => (fn === github.rest.pulls.list ? [{
+ number: 7, draft, state, head: { sha: HEAD, ref: 'feat' },
+ }] : comments),
+ rest: {
+ pulls: { list: Symbol('list') },
+ issues: {
+ listComments: Symbol('listComments'),
+ createComment: async ({ body }) => posted.push(body),
+ },
+ repos: {
+ getCommit: async () => ({ data: { commit: { committer: { date: headDate } } } }),
+ },
+ },
+ };
+ const context = { repo: { owner: 'o', repo: 'r' }, payload: {} };
+ return { github, context, posted };
+}
+
+test('requests a review for an old head with no receipt', async () => {
+ const h = harness();
+ await run({ ...h, core });
+ assert.equal(h.posted.length, 1);
+ assert.match(h.posted[0], /@codex review/);
+ assert.match(h.posted[0], /governance-nudge:a{40}/);
+});
+
+test('does not repeat the request for the same head', async () => {
+ const h = harness({ comments: [{ user: { login: 'someone' }, body: marker(HEAD) }] });
+ await run({ ...h, core });
+ assert.equal(h.posted.length, 0);
+});
+
+test('does not request a review twice when a receipt exists', async () => {
+ const h = harness({
+ comments: [{
+ user: { login: 'chatgpt-codex-connector[bot]' },
+ body: ``,
+ }],
+ });
+ await run({ ...h, core });
+ assert.equal(h.posted.length, 0);
+});
+
+test('accepts the legacy receipt marker', async () => {
+ const h = harness({
+ comments: [{ user: { login: 'chatgpt-codex-connector' }, body: `codex-review-receipt:v1 ${HEAD}` }],
+ });
+ await run({ ...h, core });
+ assert.equal(h.posted.length, 0);
+});
+
+test('waits for a fresh head', async () => {
+ const h = harness({ headDate: new Date().toISOString() });
+ await run({ ...h, core });
+ assert.equal(h.posted.length, 0);
+});
+
+test('skips drafts and closed pull requests', async () => {
+ for (const options of [{ draft: true }, { state: 'closed' }]) {
+ const h = harness(options);
+ await run({ ...h, core });
+ assert.equal(h.posted.length, 0);
+ }
+});
diff --git a/pr-governance/scripts/test-runner.cjs b/pr-governance/scripts/test-runner.cjs
new file mode 100644
index 0000000..5bbf3e7
--- /dev/null
+++ b/pr-governance/scripts/test-runner.cjs
@@ -0,0 +1,527 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const gate = require('./gate.cjs');
+const runner = require('./runner.cjs');
+const { fixture, receiptMarker, labelEvent, clearRecord, HEAD, BASE, POLICY_SHA, NOW } = require('./fixtures.cjs');
+
+const POLICY = fixture().policy;
+
+function mock(f, options = {}) {
+ const statuses = [];
+ const failures = [];
+ const warnings = [];
+ const infos = [];
+ const calls = [];
+ const summary = [];
+ const state = {
+ pull: structuredClone(f.snapshot.pr),
+ files: f.snapshot.files,
+ commits: f.snapshot.commits,
+ comments: f.snapshot.comments,
+ timeline: f.snapshot.timeline,
+ statuses: f.snapshot.statuses,
+ threads: f.snapshot.threads,
+ open: options.open || [{ number: f.snapshot.pr.number, head: { sha: f.snapshot.pr.head.sha } }],
+ branch: options.branch || BASE,
+ dependabotPolicy: options.dependabotPolicy ?? f.snapshot.dependabotPolicy,
+ };
+ const rest = {
+ pulls: {
+ get: async () => ({ data: structuredClone(state.pull) }),
+ list: async () => state.open,
+ listFiles: async () => state.files,
+ listCommits: async () => state.commits,
+ },
+ issues: {
+ listComments: async () => state.comments,
+ listEvents: async () => state.timeline,
+ },
+ repos: {
+ listCommitStatusesForRef: async () => state.statuses,
+ getBranch: async () => ({ data: { commit: { sha: state.branch } } }),
+ getContent: async ({ path, ref }) => {
+ calls.push({ method: 'getContent', params: { path, ref } });
+ if (path !== runner.DEPENDABOT_POLICY_PATH) {
+ return Promise.reject(Object.assign(new Error('Not Found'), { status: 404 }));
+ }
+ if (!state.dependabotPolicy) {
+ return Promise.reject(Object.assign(new Error('Not Found'), { status: 404 }));
+ }
+ return { data: { type: 'file', encoding: 'base64',
+ content: Buffer.from(JSON.stringify(state.dependabotPolicy)).toString('base64') } };
+ },
+ createCommitStatus: async input => {
+ const data = { ...input, creator: { id: 41898282, login: 'github-actions[bot]' } };
+ statuses.push(data);
+ state.statuses.unshift(data);
+ return { data };
+ },
+ },
+ };
+ const names = new Map([[rest.pulls.list, 'pulls'], [rest.pulls.listFiles, 'files'],
+ [rest.pulls.listCommits, 'commits'], [rest.issues.listComments, 'comments'],
+ [rest.issues.listEvents, 'events'], [rest.repos.listCommitStatusesForRef, 'statuses']]);
+ const github = {
+ rest,
+ paginate: async (method, params) => {
+ const name = names.get(method) || 'unknown';
+ calls.push({ method: name, params });
+ if (name === 'pulls') return structuredClone(state.open);
+ if (name === 'files') return structuredClone(state.files);
+ if (name === 'commits') return structuredClone(state.commits);
+ if (name === 'comments') return structuredClone(state.comments);
+ if (name === 'events') return structuredClone(state.timeline);
+ if (name === 'statuses') return structuredClone(state.statuses);
+ throw new Error(`Unexpected paginate: ${name}`);
+ },
+ graphql: async () => ({
+ repository: { pullRequest: { reviewThreads: {
+ nodes: structuredClone(state.threads), pageInfo: { hasNextPage: false, endCursor: null },
+ } } },
+ }),
+ };
+ const core = {
+ setFailed: message => failures.push(message),
+ warning: message => warnings.push(message),
+ info: message => infos.push(message),
+ summary: {
+ addHeading: text => { summary.push(text); return core.summary; },
+ addCodeBlock: text => { summary.push(text); return core.summary; },
+ addRaw: text => { summary.push(text); return core.summary; },
+ write: async () => {},
+ },
+ };
+ const context = {
+ repo: { owner: 'example-org', repo: 'example-repo' },
+ eventName: options.eventName || 'pull_request_target',
+ payload: options.payload || { action: 'opened', pull_request: { number: f.snapshot.pr.number } },
+ ref: options.ref || `refs/heads/${options.branch || 'main'}`,
+ serverUrl: 'https://github.example',
+ runId: 123,
+ };
+ return { github, context, core, statuses, failures, warnings, infos, calls, summary, state };
+}
+
+const runMock = (m, overrides = {}) => runner.run({ github: m.github, context: m.context, core: m.core,
+ policy: POLICY, policySha: POLICY_SHA, policyCommit: BASE, defaultBranch: 'main', now: NOW, ...overrides });
+
+const states = m => m.statuses.map(status => [status.context, status.state]);
+
+// ------------------------------------------------------------- targeting ---
+
+test('a single pull request event evaluates only that pull request', async () => {
+ const m = mock(fixture());
+ assert.deepEqual(await runner.selectTargets(m.github, m.context, 'main'), [42]);
+ assert.deepEqual(m.calls, []);
+});
+
+test('push and schedule sweep every open pull request', async () => {
+ for (const eventName of ['push', 'schedule']) {
+ const m = mock(fixture(), { eventName, open: [{ number: 4 }, { number: 5 }] });
+ m.context.payload = {};
+ assert.deepEqual(await runner.selectTargets(m.github, m.context, 'main'), [4, 5]);
+ }
+});
+
+test('a large sweep is never truncated', async () => {
+ const open = Array.from({ length: 40 }, (_, index) => ({ number: index + 1 }));
+ const m = mock(fixture(), { eventName: 'schedule', open });
+ m.context.payload = {};
+ assert.equal((await runner.selectTargets(m.github, m.context, 'main')).length, 40);
+});
+
+test('a closed pull request re-evaluates the remaining open ones', async () => {
+ const m = mock(fixture(), { open: [{ number: 42 }, { number: 43 }] });
+ m.context.payload = { action: 'closed', pull_request: { number: 42 } };
+ assert.deepEqual(await runner.selectTargets(m.github, m.context, 'main'), [43]);
+});
+
+test('a comment on an issue that is not a pull request evaluates nothing', async () => {
+ const m = mock(fixture(), { eventName: 'issue_comment' });
+ m.context.payload = { issue: { number: 8 } };
+ assert.deepEqual(await runner.selectTargets(m.github, m.context, 'main'), []);
+ m.context.payload = { issue: { number: 8, pull_request: {} } };
+ assert.deepEqual(await runner.selectTargets(m.github, m.context, 'main'), [8]);
+});
+
+test('a review relay evaluates the pull requests named by the run', async () => {
+ const m = mock(fixture(), { eventName: 'workflow_run' });
+ m.context.payload = { workflow_run: { pull_requests: [{ number: 42 }, { number: 7 }, { number: 42 }] } };
+ assert.deepEqual(await runner.selectTargets(m.github, m.context, 'main'), [42, 7]);
+ m.context.payload = { workflow_run: { pull_requests: [{ number: 0 }, { number: 'x' }] } };
+ assert.deepEqual(await runner.selectTargets(m.github, m.context, 'main'), []);
+});
+
+test('a review relay without pull request entries falls back to the head branch', async () => {
+ const m = mock(fixture(), { eventName: 'workflow_run', open: [{ number: 42 }] });
+ m.context.payload = { workflow_run: { pull_requests: [], head_repository: { owner: { login: 'example-org' } }, head_branch: 'feature' } };
+ assert.deepEqual(await runner.selectTargets(m.github, m.context, 'main'), [42]);
+ assert.equal(m.calls.at(-1).params.head, 'example-org:feature');
+ m.context.payload = { workflow_run: { pull_requests: [], head_branch: 'feature' } };
+ assert.deepEqual(await runner.selectTargets(m.github, m.context, 'main'), []);
+ m.context.payload = {};
+ await assert.rejects(runner.selectTargets(m.github, m.context, 'main'), /workflow run payload/);
+});
+
+test('dispatch validates the number and the default branch', async () => {
+ const m = mock(fixture(), { eventName: 'workflow_dispatch' });
+ m.context.payload = { inputs: { pr_number: '42' } };
+ assert.deepEqual(await runner.selectTargets(m.github, m.context, 'main'), [42]);
+ m.context.payload.inputs.pr_number = '1; rm -rf /';
+ await assert.rejects(runner.selectTargets(m.github, m.context, 'main'));
+ m.context.payload.inputs.pr_number = '42';
+ m.context.ref = 'refs/heads/feature';
+ await assert.rejects(runner.selectTargets(m.github, m.context, 'main'), /default branch/);
+});
+
+test('an unsupported event fails instead of guessing a target', async () => {
+ const m = mock(fixture(), { eventName: 'watch' });
+ m.context.payload = {};
+ await assert.rejects(runner.selectTargets(m.github, m.context, 'main'), /Unsupported event/);
+});
+
+test('review threads paginate to the last page and refuse a repeating cursor', async () => {
+ const m = mock(fixture());
+ let page = 0;
+ m.github.graphql = async (query, params) => {
+ page++;
+ // `Actor` has no databaseId field; asking for one makes every evaluation fail.
+ assert.doesNotMatch(query, /databaseId/);
+ assert.equal(params.cursor, page === 1 ? null : 'cursor1');
+ return { repository: { pullRequest: { reviewThreads: {
+ nodes: [{ id: `thread-${page}`, isResolved: page === 1, comments: { nodes: [{ author: { login: 'agent' } }] } }],
+ pageInfo: { hasNextPage: page === 1, endCursor: page === 1 ? 'cursor1' : null },
+ } } } };
+ };
+ const threads = await runner.readThreads(m.github, m.context.repo, 42);
+ assert.equal(threads.length, 2);
+ assert.equal(threads[1].author.login, 'agent');
+ m.github.graphql = async () => ({ repository: { pullRequest: { reviewThreads: {
+ nodes: [], pageInfo: { hasNextPage: true, endCursor: null },
+ } } } });
+ await assert.rejects(runner.readThreads(m.github, m.context.repo, 42), /page/);
+});
+
+test('every collection endpoint is paginated with the maximum page size', async () => {
+ const m = mock(fixture());
+ await runner.collect(m.github, m.context, 42, BASE);
+ assert.deepEqual([...new Set(m.calls.filter(call => call.method !== 'getContent').map(call => call.method))].sort(),
+ ['comments', 'commits', 'events', 'files', 'statuses']);
+ assert.ok(m.calls.every(call => call.method === 'getContent' || call.params.per_page === 100));
+});
+
+test('the dependency policy is read from the trusted base only', async () => {
+ const m = mock(fixture(), { dependabotPolicy: { action_files: ['.github/workflows/ci.yml'] } });
+ const snapshot = await runner.collect(m.github, m.context, 42, BASE);
+ assert.deepEqual(snapshot.dependabotPolicy, { action_files: ['.github/workflows/ci.yml'] });
+ const reads = m.calls.filter(call => call.method === 'getContent');
+ assert.deepEqual(reads.map(call => call.params), [{ path: runner.DEPENDABOT_POLICY_PATH, ref: BASE }]);
+});
+
+// -------------------------------------------------------------- verdicts ---
+
+test('a reviewed pull request publishes three success statuses and fails nothing', async () => {
+ const f = receiptMarker(fixture());
+ const m = mock(f);
+ await runMock(m);
+ assert.deepEqual(states(m), [['pr-template', 'success'], ['ai-review', 'success'], ['risk-gate', 'success']]);
+ assert.ok(m.statuses.every(status => status.sha === HEAD));
+ assert.equal(m.failures.length, 0);
+ assert.match(m.summary.join('\n'), /"reason": "review_complete"/);
+});
+
+test('an incomplete template fails the run once, after publishing', async () => {
+ const f = receiptMarker(fixture());
+ f.snapshot.pr.body = 'Nothing useful.';
+ const m = mock(f);
+ await runMock(m);
+ assert.deepEqual(states(m), [['pr-template', 'failure'], ['ai-review', 'success'], ['risk-gate', 'failure']]);
+ assert.equal(m.failures.length, 1);
+ assert.match(m.failures[0], /#42/);
+});
+
+test('an unchanged sweep appends no statuses and reports nothing', async () => {
+ const f = receiptMarker(fixture());
+ const first = mock(f);
+ await runMock(first);
+ assert.equal(first.statuses.length, 3);
+ for (let index = 0; index < 3; index++) {
+ const next = mock(f);
+ next.context.runId += index + 1;
+ await runMock(next);
+ assert.equal(next.statuses.length, 0);
+ assert.equal(next.failures.length, 0);
+ }
+});
+
+test('a matching status from another publisher is replaced, not trusted', async () => {
+ const f = receiptMarker(fixture());
+ await runMock(mock(f));
+ for (const status of f.snapshot.statuses) status.creator = { id: 1, login: 'author' };
+ const m = mock(f);
+ await runMock(m);
+ assert.equal(m.statuses.length, 3);
+});
+
+test('a closed pull request publishes nothing', async () => {
+ const f = fixture();
+ f.snapshot.pr.state = 'closed';
+ const m = mock(f);
+ await runMock(m);
+ assert.equal(m.statuses.length, 0);
+ assert.equal(m.failures.length, 0);
+});
+
+test('a non default base branch is refused', async () => {
+ const f = fixture();
+ f.snapshot.pr.base.ref = 'release';
+ const m = mock(f);
+ await runMock(m);
+ assert.ok(m.statuses.some(status => status.state === 'error'));
+ assert.equal(m.failures.length, 1);
+});
+
+// ---------------------------------------------------------- base outdated ---
+
+test('a stale base publishes a base-outdated verdict without failing the sweep', async () => {
+ const f = receiptMarker(fixture());
+ f.snapshot.pr.base.sha = 'e'.repeat(40);
+ const m = mock(f);
+ await runMock(m);
+ assert.deepEqual(states(m), [['pr-template', 'success'], ['ai-review', 'failure'], ['risk-gate', 'failure']]);
+ const verdict = m.statuses.find(status => status.context === 'risk-gate');
+ assert.equal(verdict.description, 'R1: base-outdated');
+ assert.equal(m.failures.length, 0);
+ assert.equal(m.warnings.length, 1);
+ assert.match(m.warnings[0], /rebase/);
+ const summary = JSON.parse(m.summary.find(entry => entry.trim().startsWith('{')));
+ assert.equal(summary.reason, 'base-outdated');
+ assert.equal(summary.base_sha, 'e'.repeat(40));
+ assert.equal(summary.policy_commit, BASE);
+ assert.equal(summary.tier, 'R1');
+ assert.equal(summary.allowed, false);
+});
+
+test('a stale base still reports an incomplete template', async () => {
+ const f = receiptMarker(fixture());
+ f.snapshot.pr.base.sha = 'e'.repeat(40);
+ f.snapshot.pr.body = 'Nothing useful.';
+ const m = mock(f);
+ await runMock(m);
+ assert.deepEqual(states(m), [['pr-template', 'failure'], ['ai-review', 'failure'], ['risk-gate', 'failure']]);
+ assert.equal(m.failures.length, 0);
+});
+
+test('one stale pull request does not stop the sweep from evaluating the rest', async () => {
+ const f = receiptMarker(fixture());
+ const other = '9'.repeat(40);
+ const m = mock(f, { eventName: 'push', open: [{ number: 42, head: { sha: HEAD } }, { number: 43, head: { sha: other } }] });
+ m.context.payload = {};
+ const pull = structuredClone(m.state.pull);
+ m.github.rest.pulls.get = async input => ({
+ data: input.pull_number === 42
+ ? { ...structuredClone(pull), base: { ...pull.base, sha: 'e'.repeat(40) } }
+ : { ...structuredClone(pull), head: { sha: other } },
+ });
+ const result = await runMock(m);
+ // The stale pull request is a stable verdict, not a failure; the sweep still
+ // evaluated the second one and failed only for it.
+ assert.equal(m.failures.length, 1);
+ assert.match(m.failures[0], /#43/);
+ assert.doesNotMatch(m.failures[0], /#42/);
+ assert.ok(m.statuses.some(status => status.description === 'R1: base-outdated'));
+ assert.deepEqual([...new Set(m.statuses.map(status => status.sha))].sort(), [other, HEAD].sort());
+ assert.deepEqual(result.failures.map(item => item.number), [43]);
+});
+
+// ------------------------------------------------------------ shared head ---
+
+test('a shared head publishes error statuses and never a pass', async () => {
+ const f = receiptMarker(fixture());
+ const m = mock(f, { open: [{ number: 42, head: { sha: HEAD } }, { number: 99, head: { sha: HEAD } }] });
+ const result = await runMock(m);
+ assert.deepEqual(states(m), [['pr-template', 'error'], ['ai-review', 'error'], ['risk-gate', 'error']]);
+ assert.ok(m.statuses.every(status => /shares this head/.test(status.description)));
+ assert.equal(m.failures.length, 1);
+ assert.deepEqual(result.failures.map(item => item.message), ['SHARED_HEAD']);
+});
+
+test('a sibling that appears during evaluation still blocks the pass', async () => {
+ const f = receiptMarker(fixture());
+ const m = mock(f);
+ let lists = 0;
+ const list = m.github.paginate;
+ m.github.paginate = async (method, params) => {
+ if (method === m.github.rest.pulls.list) {
+ lists++;
+ if (lists === 2) {
+ m.state.open = [{ number: 42, head: { sha: HEAD } }, { number: 77, head: { sha: HEAD } }];
+ }
+ }
+ return list(method, params);
+ };
+ await runMock(m);
+ assert.ok(m.statuses.some(status => status.state === 'error'));
+ assert.ok(m.statuses.every(status => status.state !== 'success'));
+});
+
+// ------------------------------------------------------------ status limit ---
+
+test('a context with 998 statuses still publishes a real verdict', async () => {
+ const f = receiptMarker(fixture());
+ f.snapshot.statuses = Array.from({ length: 998 }, () => ({ context: 'risk-gate', state: 'success',
+ description: 'Old success', creator: { id: 41898282, login: 'github-actions[bot]' } }));
+ const m = mock(f);
+ await runMock(m);
+ assert.equal(m.statuses.find(status => status.context === 'risk-gate').state, 'success');
+ assert.equal(m.failures.length, 0);
+});
+
+test('the last status slot is reserved for a blocking state', async () => {
+ const f = receiptMarker(fixture());
+ f.snapshot.statuses = Array.from({ length: 999 }, () => ({ context: 'risk-gate', state: 'success',
+ description: 'Old success', creator: { id: 41898282, login: 'github-actions[bot]' } }));
+ const m = mock(f);
+ const result = await runMock(m);
+ const writes = m.statuses.filter(status => status.context === 'risk-gate');
+ assert.equal(writes.length, 1);
+ assert.equal(writes[0].state, 'error');
+ assert.match(writes[0].description, /status limit/);
+ assert.equal(m.failures.length, 1);
+ assert.deepEqual(result.failures.map(item => item.message), ['STATUS_LIMIT']);
+});
+
+test('an exhausted context reports failure and never claims publication', async () => {
+ const f = receiptMarker(fixture());
+ f.snapshot.statuses = Array.from({ length: 1000 }, () => ({ context: 'risk-gate', state: 'success',
+ description: 'Old success', creator: { id: 41898282, login: 'github-actions[bot]' } }));
+ const m = mock(f);
+ await runMock(m);
+ assert.equal(m.statuses.filter(status => status.context === 'risk-gate').length, 0);
+ assert.equal(m.failures.length, 1);
+ assert.equal(f.snapshot.statuses.find(status => status.context === 'risk-gate').state, 'success');
+});
+
+// ------------------------------------------------------------- integrity ---
+
+for (const field of ['head', 'base', 'body', 'evidence', 'thread', 'labels', 'policy']) {
+ test(`a concurrent ${field} change during evaluation publishes only errors`, async () => {
+ const f = receiptMarker(fixture());
+ const m = mock(f);
+ let reads = 0;
+ const get = m.github.rest.pulls.get;
+ m.github.rest.pulls.get = async () => {
+ if (++reads === 3) {
+ if (field === 'head') m.state.pull.head = { sha: 'e'.repeat(40) };
+ if (field === 'base') m.state.pull.base = { ...m.state.pull.base, sha: 'e'.repeat(40) };
+ if (field === 'body') m.state.pull.body = 'changed during evaluation';
+ if (field === 'evidence') f.snapshot.comments[0].updated_at = new Date((NOW + 30) * 1000).toISOString();
+ if (field === 'thread') f.snapshot.threads.push({ id: 'PRRT_late', isResolved: false, author: { login: 'chatgpt-codex-connector' } });
+ if (field === 'labels') m.state.pull.labels = [{ name: 'owner-approved' }];
+ if (field === 'policy') m.state.branch = 'f'.repeat(40);
+ }
+ return get();
+ };
+ await runMock(m);
+ assert.ok(m.statuses.some(status => status.state === 'error'), 'an error status must be published');
+ assert.ok(m.statuses.every(status => status.state !== 'success'), 'no success may be published');
+ assert.equal(m.failures.length, 1);
+ });
+}
+
+test('an unavailable API publishes errors and never a success', async () => {
+ const m = mock(fixture());
+ m.github.paginate = async () => { throw new Error('API unavailable'); };
+ await runMock(m);
+ assert.ok(m.statuses.some(status => status.state === 'error'));
+ assert.ok(m.statuses.every(status => status.state !== 'success'));
+ assert.equal(m.failures.length, 1);
+});
+
+test('a policy that cannot be read marks every target and fails', async () => {
+ const f = receiptMarker(fixture());
+ const m = mock(f);
+ const policy = gate.evaluate(f.snapshot, f.policy, POLICY_SHA, NOW);
+ assert.equal(policy.allowed, true);
+ const result = await runMock(m, { policy: null, policySha: null });
+ assert.deepEqual(states(m), [['pr-template', 'error'], ['ai-review', 'error'], ['risk-gate', 'error']]);
+ assert.ok(m.statuses.every(status => /policy unavailable/i.test(status.description)));
+ assert.equal(m.failures.length, 1);
+ assert.deepEqual(result.failures.map(item => item.message), ['policy unavailable']);
+});
+
+// ------------------------------------------------------------------ modes ---
+
+test('local mode evaluates and claims nothing', async () => {
+ const f = receiptMarker(fixture());
+ const policy = { ...POLICY, mode: 'local' };
+ const m = mock(f);
+ await runMock(m, { policy });
+ assert.deepEqual(m.statuses, []);
+ assert.equal(m.failures.length, 0);
+ assert.match(m.summary.join('\n'), /allowed/);
+});
+
+test('report only mode publishes advisory statuses and never fails the run', async () => {
+ const f = receiptMarker(fixture());
+ f.snapshot.pr.body = 'Nothing useful.';
+ const m = mock(f);
+ await runMock(m, { policy: { ...POLICY, mode: 'report-only' } });
+ assert.equal(m.statuses.length, 3);
+ assert.ok(m.statuses.every(status => status.description.startsWith('report-only: ')));
+ assert.equal(m.failures.length, 0);
+ assert.equal(m.warnings.length, 1);
+});
+
+// -------------------------------------------------------------- disclosure ---
+
+test('no comment body is echoed into a status or the summary', async () => {
+ const f = receiptMarker(fixture());
+ const marker = 'SECRET-PROSE-THAT-MUST-NOT-APPEAR';
+ f.snapshot.comments[0].body += `\n${marker}\n`;
+ const m = mock(f);
+ await runMock(m);
+ const exposed = JSON.stringify([m.statuses, m.summary, m.failures, m.warnings, m.infos]);
+ assert.equal(exposed.includes(marker), false);
+});
+
+test('labels bound to a stale head are reported in the summary, not trusted', async () => {
+ const f = receiptMarker(fixture({ path: 'config/settings.json' }));
+ const record = labelEvent(f);
+ clearRecord(f, record);
+ const m = mock(f);
+ await runMock(m);
+ assert.equal(m.statuses.find(status => status.context === 'risk-gate').state, 'success');
+ assert.match(m.summary.join('\n'), /cleared by owner-approved/);
+});
+
+test('the runner reports every failing pull request once at the end of a sweep', async () => {
+ const f = fixture();
+ const m = mock(f, { eventName: 'schedule', open: [{ number: 42, head: { sha: HEAD } }, { number: 43, head: { sha: HEAD } }] });
+ m.context.payload = {};
+ const evaluated = new Set();
+ const get = m.github.rest.pulls.get;
+ m.github.rest.pulls.get = async input => { evaluated.add(input.pull_number); return get(input); };
+ await runMock(m);
+ assert.deepEqual([...evaluated].sort(), [42, 43]);
+ assert.equal(m.failures.length, 1);
+ assert.match(m.failures[0], /#42, #43/);
+});
+
+test('a failing status publication does not abort later pull requests', async () => {
+ const f = fixture();
+ const m = mock(f, { eventName: 'schedule', open: [{ number: 42, head: { sha: HEAD } }, { number: 43, head: { sha: HEAD } }] });
+ m.context.payload = {};
+ const evaluated = new Set();
+ const get = m.github.rest.pulls.get;
+ m.github.rest.pulls.get = async input => { evaluated.add(input.pull_number); return get(input); };
+ m.github.rest.repos.createCommitStatus = async input => {
+ m.statuses.push(input);
+ throw new Error('statuses unavailable');
+ };
+ await runMock(m);
+ assert.deepEqual([...evaluated].sort(), [42, 43]);
+ assert.equal(m.failures.length, 1);
+});
diff --git a/pr-governance/scripts/test_secret_scan.py b/pr-governance/scripts/test_secret_scan.py
new file mode 100644
index 0000000..f5e2667
--- /dev/null
+++ b/pr-governance/scripts/test_secret_scan.py
@@ -0,0 +1,469 @@
+import importlib.util
+import contextlib
+import io
+import json
+import os
+import secrets
+import shutil
+import string
+import subprocess
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+SPEC = importlib.util.spec_from_file_location('secret_scan', Path(__file__).with_name('secret_scan.py'))
+SCANNER = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(SCANNER)
+BINARY = os.environ.get('GITLEAKS_BIN', 'gitleaks')
+
+
+def fake_value(length=32):
+ return ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(length))
+
+
+class SecretScanTests(unittest.TestCase):
+ def setUp(self):
+ self.directory = tempfile.TemporaryDirectory()
+ self.addCleanup(self.directory.cleanup)
+ self.root = Path(self.directory.name)
+ self.output = self.root / 'report.json'
+ self.source = self.root / 'payload'
+ self.source.mkdir()
+
+ def detect(self, text, filename='credentials.properties'):
+ (self.source / filename).write_text(text)
+ return SCANNER.scan(BINARY, 'dir', self.source, self.output)
+
+ def suppression_repository(self):
+ subprocess.run(['git', 'init', '--quiet', str(self.source)], check=True,
+ capture_output=True)
+ self.git('config', 'user.name', 'Synthetic Test')
+ self.git('config', 'user.email', 'fixture@example.invalid')
+ (self.source / 'clean.txt').write_text('clean fixture\n')
+ self.git('add', '.')
+ self.git('commit', '--quiet', '-m', 'test: clean fixture')
+ return self.git('rev-parse', 'HEAD')
+
+ def git(self, *arguments):
+ return subprocess.run(['git', '-C', str(self.source), *arguments], check=True,
+ capture_output=True, text=True).stdout.strip()
+
+ def suppressed_payload(self, suppression, mode):
+ self.source = self.root / ('payload-' + mode)
+ self.source.mkdir()
+ base = self.suppression_repository()
+ payload = self.source / 'credentials.properties'
+ payload.write_text('password=' + fake_value() + '\n')
+ if suppression == 'comment':
+ payload.write_text(payload.read_text().rstrip() + ' # gitleaks:allow\n')
+ self.git('add', '.')
+ self.git('commit', '--quiet', '-m', 'test: synthetic credential')
+ if suppression == 'ignore':
+ findings = SCANNER.scan(BINARY, mode, self.source, self.output)
+ self.assertTrue(findings)
+ (self.source / '.gitleaksignore').write_text(
+ '\n'.join(row['Fingerprint'] for row in findings) + '\n')
+ self.git('add', '.gitleaksignore')
+ self.git('commit', '--quiet', '-m', 'test: source suppression')
+ return base
+
+ def test_helper_ignores_allow_comments(self):
+ for mode in ('dir', 'git'):
+ with self.subTest(mode=mode):
+ self.suppressed_payload('comment', mode)
+ self.assertTrue(SCANNER.scan(BINARY, mode, self.source, self.output))
+
+ def test_helper_rejects_source_ignore(self):
+ for mode in ('dir', 'git'):
+ with self.subTest(mode=mode):
+ self.suppressed_payload('ignore', mode)
+ with self.assertRaises(RuntimeError):
+ SCANNER.scan(BINARY, mode, self.source, self.output)
+ (self.source / '.gitleaksignore').unlink()
+
+ def test_ignore_rejection_is_redacted_at_artifact_sink(self):
+ value = fake_value()
+ nested = self.source / 'nested'
+ nested.mkdir()
+ (nested / '.gitleaksignore').write_text(value)
+ output = io.StringIO()
+ with patch('sys.argv', ['secret_scan.py', 'artifacts', '--gitleaks', BINARY]), \
+ patch.dict(os.environ, {'ARTIFACT_PATHS': str(self.source)}), \
+ contextlib.redirect_stdout(output):
+ self.assertEqual(SCANNER.main(), 2)
+ self.assertNotIn(value, output.getvalue())
+ self.assertNotIn(str(self.source), output.getvalue())
+
+ def range_scan(self, base, head=None):
+ return SCANNER.range_scan(BINARY, base, head or self.git('rev-parse', 'HEAD'), self.source)
+
+ def test_range_scan_blocks_allow_comment(self):
+ base = self.suppressed_payload('comment', 'git')
+ self.assertEqual(self.range_scan(base), 1)
+
+ def test_range_scan_blocks_source_ignore(self):
+ base = self.suppressed_payload('ignore', 'git')
+ with self.assertRaises(RuntimeError):
+ self.range_scan(base)
+
+ def test_range_scan_clean_control(self):
+ base = self.suppression_repository()
+ (self.source / 'clean.txt').write_text('another clean fixture\n')
+ self.git('add', '.')
+ self.git('commit', '--quiet', '-m', 'test: clean addition')
+ self.assertEqual(self.range_scan(base), 0)
+
+ def test_range_scan_unknown_base_fails(self):
+ self.suppressed_payload('comment', 'git')
+ with self.assertRaises(RuntimeError):
+ self.range_scan('f' * 40)
+
+ def test_range_scan_empty_range_passes(self):
+ base = self.suppression_repository()
+ self.assertEqual(self.range_scan(base), 0)
+
+ def test_range_scan_survives_non_utf8_history(self):
+ base = self.suppression_repository()
+ (self.source / 'legacy.txt').write_bytes(b'\xd2\xbb\xb6\xce\xce\xc4\xb1\xbe\n')
+ self.git('add', '.')
+ self.git('commit', '--quiet', '-m', 'test: legacy encoding')
+ self.assertEqual(self.range_scan(base), 0)
+
+ def test_range_scan_unknown_head_fails(self):
+ base = self.suppression_repository()
+ with self.assertRaises(RuntimeError):
+ self.range_scan(base, 'f' * 40)
+
+ def test_missing_blob_blocks_history_and_range(self):
+ base = self.suppressed_payload('comment', 'git')
+ # Both tips resolve, but reading the complete patch must fail.
+ blob = self.git('rev-parse', 'HEAD:credentials.properties')
+ (self.source / '.git' / 'objects' / blob[:2] / blob[2:]).unlink()
+ with self.assertRaises(RuntimeError):
+ SCANNER.scan(BINARY, 'git', self.source, self.output)
+ with self.assertRaises(RuntimeError):
+ self.range_scan(base)
+
+ def test_helper_history_git_failure_blocks(self):
+ (self.source / 'credentials.properties').write_text('password=' + fake_value())
+ with self.assertRaises(RuntimeError):
+ SCANNER.scan(BINARY, 'git', self.source, self.output)
+
+ def test_history_cli_git_failure_blocks(self):
+ value = fake_value()
+ (self.source / 'credentials.properties').write_text('password=' + value)
+ shutil.copyfile(SCANNER.ROOT / '.gitleaks.toml', self.source / '.gitleaks.toml')
+ baseline = self.root / 'baseline.json'
+ baseline.write_text('[]')
+ output = io.StringIO()
+ with patch.object(SCANNER, 'ROOT', self.source), \
+ patch('sys.argv', ['scan', 'history', '--gitleaks', BINARY,
+ '--baseline', str(baseline), '--summary',
+ str(self.root / 'summary.json')]), \
+ contextlib.redirect_stdout(output):
+ self.assertNotEqual(SCANNER.main(), 0)
+ self.assertNotIn(value, output.getvalue())
+ self.assertNotIn(str(self.source), output.getvalue())
+
+ def test_history_cannot_inherit_parent_repository(self):
+ self.suppression_repository()
+ nested = self.source / 'nested'
+ nested.mkdir()
+ with self.assertRaises(RuntimeError):
+ SCANNER.scan(BINARY, 'git', nested, self.output)
+
+ def test_missing_history_source_blocks(self):
+ with self.assertRaises(RuntimeError):
+ SCANNER.scan(BINARY, 'git', self.source / 'absent', self.output)
+
+ def test_history_without_head_blocks(self):
+ subprocess.run(['git', 'init', '--quiet', str(self.source)], check=True,
+ capture_output=True)
+ with self.assertRaises(RuntimeError):
+ SCANNER.scan(BINARY, 'git', self.source, self.output)
+
+ def test_helper_history_enumeration_failure_blocks(self):
+ self.suppression_repository()
+ result = subprocess.run
+
+ def fail_history(command, **kwargs):
+ if command[0] == 'git' and 'log' in command:
+ return subprocess.CompletedProcess(command, 128)
+ return result(command, **kwargs)
+
+ with patch.object(SCANNER.subprocess, 'run', side_effect=fail_history), \
+ self.assertRaises(RuntimeError):
+ SCANNER.scan(BINARY, 'git', self.source, self.output)
+
+ def test_history_clean_control(self):
+ self.suppression_repository()
+ self.assertEqual(SCANNER.scan(BINARY, 'git', self.source, self.output), [])
+
+ def test_mongodb_protocols_are_detected_without_keywords(self):
+ for protocol in ('mongodb', 'mongodb+srv'):
+ with self.subTest(protocol=protocol):
+ findings = self.detect(f'{protocol}://fixture:{fake_value()}@example.invalid/db')
+ self.assertIn('mongodb-userinfo-password', {row['RuleID'] for row in findings})
+
+ def test_alibaba_access_key_secret_is_detected(self):
+ for name in ('ALIBABA_ACCESS_KEY_SECRET', 'accessKeySecret', 'access_key_secret',
+ 'access-key', 'accessKey'):
+ with self.subTest(name=name):
+ findings = self.detect(name + '=' + fake_value(30))
+ self.assertIn('alibaba-access-key-secret', {row['RuleID'] for row in findings})
+
+ def test_configuration_credentials_are_detected(self):
+ for name in ('jwt.secret', 'wechat.appsecret', 'request.validate.token',
+ 'PASSWORD', 'passwd', 'access-key', 'aliyunAccessKey'):
+ with self.subTest(name=name):
+ findings = self.detect(f'{name}={fake_value()}')
+ self.assertIn('configuration-secret-value', {row['RuleID'] for row in findings})
+
+ def test_yaml_and_env_assignments_are_detected(self):
+ for filename, assignment in (('config.yaml', ' appsecret: "'), ('.env', 'export TOKEN="')):
+ with self.subTest(filename=filename):
+ findings = self.detect(assignment + fake_value() + '"', filename)
+ self.assertIn('configuration-secret-value', {row['RuleID'] for row in findings})
+
+ def test_clean_placeholders_pass(self):
+ self.assertEqual(self.detect('jwt.secret=${JWT_SECRET}\npassword=\n'
+ 'token=${LONG_TOKEN_VARIABLE}\ntoken=${_TOKEN_1}\n'
+ 'token=your-token-placeholder\npassword=xxxxxxxxxxxxxxxx\n'), [])
+
+ def test_punctuation_in_password_is_detected(self):
+ findings = self.detect('password=' + fake_value(16) + '&$*()!<>')
+ self.assertIn('configuration-secret-value', {row['RuleID'] for row in findings})
+
+ def test_placeholder_with_literal_text_is_detected(self):
+ for value in ('${X}' + fake_value(), fake_value() + '${X}',
+ '${LONG_TOKEN_VARIABLE}' + fake_value(),
+ '${LONG_TOKEN_VARIABLE};' + fake_value(),
+ '${LONG_TOKEN_VARIABLE}#' + fake_value(),
+ '${LONG_TOKEN_VARIABLE:-' + fake_value() + '}',
+ '${LONG_TOKEN_VARIABLE}${OTHER_VARIABLE}'):
+ findings = self.detect('token=' + value)
+ self.assertIn('configuration-secret-value', {row['RuleID'] for row in findings})
+
+ def test_alibaba_access_key_id_is_not_a_secret_assignment(self):
+ for name in ('accessKeyId', 'access_key_id', 'ALIBABA_ACCESS_KEY_ID'):
+ with self.subTest(name=name):
+ findings = self.detect(name + '=' + fake_value(30))
+ rules = {row['RuleID'] for row in findings}
+ self.assertNotIn('alibaba-access-key-secret', rules)
+ self.assertNotIn('configuration-secret-value', rules)
+
+ def test_short_low_entropy_password_is_detected(self):
+ for length in (8, 12, 15):
+ with self.subTest(length=length):
+ value = (''.join(secrets.SystemRandom().sample(string.ascii_lowercase, 2)) * length)[:length]
+ findings = self.detect('password=' + value)
+ self.assertIn('configuration-secret-value', {row['RuleID'] for row in findings})
+
+ def test_junit_xml_credentials_block_artifacts(self):
+ for prefix in ('',
+ 'TEST-fixture.xml')
+ self.assertIn('configuration-secret-value', {row['RuleID'] for row in findings})
+ output = io.StringIO()
+ with contextlib.redirect_stdout(output):
+ self.assertEqual(SCANNER.artifacts(BINARY, str(self.source / '*.xml')), 1)
+ self.assertNotIn(value, output.getvalue())
+
+ def test_junit_xml_placeholder_passes(self):
+ self.assertEqual(self.detect('', 'TEST-fixture.xml'), [])
+
+ def test_history_baseline_counts_same_identity_findings(self):
+ repository = self.root / 'repository'
+ subprocess.run(['git', 'init', '--quiet', str(repository)], check=True)
+
+ def git(*arguments):
+ return subprocess.run(['git', '-C', str(repository), *arguments], check=True,
+ capture_output=True, text=True).stdout.strip()
+
+ git('config', 'user.name', 'Synthetic Test')
+ git('config', 'user.email', 'fixture@example.invalid')
+ shutil.copyfile(SCANNER.ROOT / '.gitleaks.toml', repository / '.gitleaks.toml')
+ # Different matches on the same line receive the same Gitleaks identity.
+ (repository / 'credentials.txt').write_text(' '.join(
+ f'mongodb://fixture:{fake_value()}@example.invalid/db' for _ in range(2)))
+ git('add', '.')
+ git('commit', '--quiet', '-m', 'test: same-line synthetic credentials')
+ baseline = repository / '.gitleaks-baseline.json'
+ summary = self.root / 'summary.json'
+ with patch.object(SCANNER, 'ROOT', repository):
+ findings = SCANNER.scan(BINARY, 'git', repository, self.output)
+ matches = [row for row in findings if row['RuleID'] == 'mongodb-userinfo-password']
+ self.assertEqual(len(matches), 2)
+ self.assertEqual(SCANNER.identity(matches[0]), SCANNER.identity(matches[1]))
+ baseline.write_text(json.dumps([SCANNER.project(row) for row in findings]))
+ self.assertEqual(SCANNER.history(BINARY, baseline, summary), 0)
+ # Simulate a baseline approved before a rule exposed the second match.
+ findings.remove(matches[0])
+ baseline.write_text(json.dumps([SCANNER.project(row) for row in findings]))
+ self.assertEqual(SCANNER.history(BINARY, baseline, summary), 1)
+ rows = json.loads(summary.read_text())
+ self.assertEqual(rows, {'new_findings': 1, 'by_rule': {'mongodb-userinfo-password': 1}})
+
+ def test_history_scan_includes_merge_commit_diffs(self):
+ repository = self.root / 'repository'
+ subprocess.run(['git', 'init', '--quiet', '-b', 'main', str(repository)], check=True)
+ subprocess.run(['git', '-C', str(repository), 'config', 'user.name', 'Synthetic Test'], check=True)
+ subprocess.run(['git', '-C', str(repository), 'config', 'user.email', 'fixture@example.invalid'], check=True)
+ shutil.copyfile(SCANNER.ROOT / '.gitleaks.toml', repository / '.gitleaks.toml')
+ payload = repository / 'config.txt'
+
+ def git(*arguments):
+ return subprocess.run(['git', '-C', str(repository), *arguments], check=True,
+ capture_output=True, text=True).stdout.strip()
+
+ def commit(message, text):
+ payload.write_text(text)
+ git('add', 'config.txt')
+ git('commit', '--quiet', '-m', message)
+
+ commit('test: base', 'setting=base\n')
+ base = git('rev-parse', 'HEAD')
+ git('checkout', '--quiet', '-b', 'feature')
+ commit('test: feature', 'setting=feature\n')
+ git('checkout', '--quiet', 'main')
+ commit('test: main', 'setting=main\n')
+ subprocess.run(['git', '-C', str(repository), 'merge', '--no-ff', 'feature',
+ '-m', 'test: conflicting feature'], capture_output=True, check=False)
+ # Resolving the conflict introduces a credential that exists only in the
+ # merge commit, not in either parent.
+ commit('test: merge resolution', 'setting=main\npassword=' + fake_value() + '\n')
+ with patch.object(SCANNER, 'ROOT', repository):
+ # The pull request range walks the same merge commit, so a credential
+ # that exists only in a conflict resolution must block the scan.
+ self.assertEqual(SCANNER.range_scan(BINARY, base, git('rev-parse', 'HEAD'), root=repository), 1)
+ findings = SCANNER.scan(BINARY, 'git', repository, self.output)
+ self.assertIn('configuration-secret-value', {row['RuleID'] for row in findings})
+
+ def test_fixture_directories_still_detect_unapproved_credentials(self):
+ for directory in ('tests',):
+ with self.subTest(directory=directory):
+ repository = self.root / directory.replace('/', '-')
+ repository.mkdir()
+ subprocess.run(['git', 'init', '--quiet', str(repository)], check=True)
+ payload = repository / directory / 'credentials.properties'
+ payload.parent.mkdir(parents=True)
+ payload.write_text('token=fake-' + fake_value() + '\n')
+ shutil.copyfile(SCANNER.ROOT / '.gitleaks.toml', repository / '.gitleaks.toml')
+ with patch.object(SCANNER, 'ROOT', repository):
+ subprocess.run(['git', '-C', str(repository), 'add', '.'], check=True)
+ subprocess.run(['git', '-C', str(repository), '-c', 'user.name=Synthetic Test',
+ '-c', 'user.email=fixture@example.invalid', 'commit', '--quiet',
+ '-m', 'test: approved synthetic fixture'], check=True)
+ self.assertEqual(SCANNER.scan(BINARY, 'git', repository, self.output), [])
+ # Keep the default rule's entropy and word filters deterministic.
+ api_value = ''.join(character + str(index % 10) for index, character in enumerate(
+ secrets.SystemRandom().sample(string.ascii_letters, 16)))
+ payload.write_text('token=fake-' + fake_value() + '\n'
+ 'token=${X}' + fake_value() + '\n'
+ 'mongodb://fixture:' + fake_value() + '@example.invalid/db\n'
+ 'ALIBABA_ACCESS_KEY_SECRET=' + fake_value(30) + '\n'
+ 'api_key=' + api_value + '\n')
+ subprocess.run(['git', '-C', str(repository), 'add', '.'], check=True)
+ subprocess.run(['git', '-C', str(repository), '-c', 'user.name=Synthetic Test',
+ '-c', 'user.email=fixture@example.invalid', 'commit', '--quiet',
+ '-m', 'test: unapproved synthetic credentials'], check=True)
+ findings = SCANNER.scan(BINARY, 'git', repository, self.output)
+ expected = {'configuration-secret-value', 'mongodb-userinfo-password',
+ 'alibaba-access-key-secret', 'generic-api-key'}
+ self.assertTrue(expected <= {row['RuleID'] for row in findings},
+ {row['RuleID'] for row in findings})
+ # Directory scanning traverses relative paths before checking values.
+ result = subprocess.run(
+ [BINARY, 'dir', '.', '--config', str(repository / '.gitleaks.toml'),
+ '--redact', '--no-banner', '--report-format', 'json',
+ '--report-path', str(self.output)],
+ cwd=repository, capture_output=True, check=False)
+ self.assertEqual(result.returncode, 1)
+ findings = json.loads(self.output.read_text())
+ self.assertTrue(expected <= {row['RuleID'] for row in findings},
+ {row['RuleID'] for row in findings})
+
+ def test_runtime_uri_expression_exception_does_not_skip_source_file(self):
+ filename = '.github/scripts/test_secret_scan.py'
+ (self.source / filename).parent.mkdir(parents=True)
+ # This source expression is evaluated only when the fixture is generated.
+ expression = 'mongodb://fixture:' + '{fake_value()}' + '@example.invalid/db'
+ self.assertEqual(self.detect(expression, filename), [])
+ findings = self.detect(expression + '\n' + 'mongodb://fixture:' + fake_value()
+ + '@example.invalid/db', filename)
+ self.assertIn('mongodb-userinfo-password', {row['RuleID'] for row in findings})
+ # The same literal is not exempted outside the scanner regression source.
+ findings = self.detect(expression, 'other.py')
+ self.assertIn('mongodb-userinfo-password', {row['RuleID'] for row in findings})
+
+ def test_clean_directory_passes(self):
+ self.assertEqual(SCANNER.scan(BINARY, 'dir', self.source, self.output), [])
+
+ def test_fake_value_outside_test_paths_is_not_allowlisted(self):
+ findings = self.detect('token=fake-' + fake_value())
+ self.assertTrue(findings)
+
+ def test_artifact_findings_block_upload(self):
+ value = fake_value()
+ self.detect('jwt.secret=' + value)
+ output = io.StringIO()
+ with contextlib.redirect_stdout(output):
+ self.assertEqual(SCANNER.artifacts(BINARY, str(self.source / '*.properties')), 1)
+ self.assertIn('RuleID', output.getvalue())
+ self.assertNotIn(value, output.getvalue())
+
+ def test_empty_artifact_glob_passes_without_scanning_workspace(self):
+ self.detect('jwt.secret=' + fake_value())
+ self.assertEqual(SCANNER.artifacts(BINARY, str(self.source / '*.absent')), 0)
+
+ def test_missing_binary_fails(self):
+ with self.assertRaises(OSError):
+ SCANNER.scan(str(self.root / 'missing'), 'dir', self.source, self.output)
+
+ def test_unredacted_baseline_is_rejected(self):
+ self.output.write_text(json.dumps([{'Secret': fake_value()}]))
+ with self.assertRaises(ValueError):
+ SCANNER.load_baseline(self.output)
+
+ def test_history_baseline_passes_and_new_remote_commit_fails(self):
+ repository = self.root / 'repository'
+ subprocess.run(['git', 'init', '--quiet', '-b', 'security/leak-prevention-20261101', str(repository)], check=True)
+
+ def git(*arguments):
+ return subprocess.run(['git', '-C', str(repository), *arguments], check=True,
+ capture_output=True, text=True).stdout.strip()
+
+ git('config', 'user.name', 'Synthetic Test')
+ git('config', 'user.email', 'fixture@example.invalid')
+ shutil.copyfile(SCANNER.ROOT / '.gitleaks.toml', repository / '.gitleaks.toml')
+ payload = repository / 'credentials.properties'
+ payload.write_text('jwt.secret=' + fake_value())
+ git('add', '.')
+ git('commit', '--quiet', '-m', 'test: synthetic historical fixture')
+ old_commit = git('rev-parse', 'HEAD')
+ baseline = repository / '.gitleaks-baseline.json'
+ summary = self.root / 'summary.json'
+ with patch.object(SCANNER, 'ROOT', repository):
+ self.assertEqual(SCANNER.history(BINARY, baseline, summary, create=True), 0)
+ self.assertTrue(SCANNER.load_baseline(baseline))
+ self.assertTrue(SCANNER.scan(BINARY, 'git', repository, self.output, baseline))
+ self.assertEqual(SCANNER.history(BINARY, baseline, summary), 0)
+ payload.write_text('jwt.secret=' + fake_value())
+ git('add', 'credentials.properties')
+ git('commit', '--quiet', '-m', 'test: new synthetic credential')
+ git('update-ref', 'refs/remotes/origin/security/leak-prevention-20261101', 'HEAD')
+ git('checkout', '--quiet', '--detach', old_commit)
+ self.assertEqual(SCANNER.history(BINARY, baseline, summary), 1)
+ rows = json.loads(summary.read_text())
+ self.assertEqual(rows['new_findings'], 1)
+ self.assertEqual(set(rows), {'new_findings', 'by_rule'})
+ self.assertTrue(all(isinstance(count, int) for count in rows['by_rule'].values()))
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/pr-governance/templates/codex-nudge.yml b/pr-governance/templates/codex-nudge.yml
new file mode 100644
index 0000000..352362a
--- /dev/null
+++ b/pr-governance/templates/codex-nudge.yml
@@ -0,0 +1,30 @@
+name: Review request
+
+# Copy to .github/workflows/codex-nudge.yml and replace __GOVERNANCE_REPO__.
+# Asks the reviewer for one review per head that has no receipt yet. It does
+# not judge the review; the risk gate remains the only pass/fail authority.
+
+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: __GOVERNANCE_REPO__/pr-governance@__GOVERNANCE_SHA__
+ with:
+ mode: nudge
diff --git a/pr-governance/templates/gitleaks.yml b/pr-governance/templates/gitleaks.yml
new file mode 100644
index 0000000..8e1706b
--- /dev/null
+++ b/pr-governance/templates/gitleaks.yml
@@ -0,0 +1,29 @@
+name: Secret scan
+
+# Copy to .github/workflows/gitleaks.yml and replace __GOVERNANCE_REPO__.
+# The job name is the required status check context; keep it as `Gitleaks`.
+# The scanner, its config and its pinned binary come from the governance
+# repository, so a pull request cannot change the rules that judge it.
+
+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: __GOVERNANCE_REPO__/pr-governance@__GOVERNANCE_SHA__
+ with:
+ mode: scan
diff --git a/pr-governance/templates/pr-review-signal.yml b/pr-governance/templates/pr-review-signal.yml
new file mode 100644
index 0000000..0d9fc3b
--- /dev/null
+++ b/pr-governance/templates/pr-review-signal.yml
@@ -0,0 +1,25 @@
+name: PR review signal
+
+# Copy to .github/workflows/pr-review-signal.yml and replace nothing: this
+# workflow holds no permissions and runs no repository code. It exists because a
+# review event on a fork or Dependabot pull request gets a read-only token, so
+# the gate that publishes statuses has to be started by `workflow_run` instead.
+# Keep the workflow name; risk-gate.yml refers to it.
+
+on:
+ pull_request_review:
+ types: [submitted, edited, dismissed]
+ pull_request_review_comment:
+ types: [created, edited, deleted]
+
+permissions:
+ contents: read
+
+jobs:
+ signal:
+ name: Signal review change
+ runs-on: ubuntu-latest
+ timeout-minutes: 2
+ steps:
+ - name: Signal
+ run: echo "review event recorded"
diff --git a/pr-governance/templates/risk-gate.yml b/pr-governance/templates/risk-gate.yml
new file mode 100644
index 0000000..1c68191
--- /dev/null
+++ b/pr-governance/templates/risk-gate.yml
@@ -0,0 +1,93 @@
+name: PR risk gate
+
+# Copy to .github/workflows/risk-gate.yml and replace __GOVERNANCE_REPO__ with
+# the organization's governance repository (for example Pigbibi/governance).
+# The job names in this file are the required status check contexts; keep them.
+
+on:
+ pull_request_target:
+ types: [opened, reopened, synchronize, edited, labeled, unlabeled, ready_for_review, closed]
+ issue_comment:
+ types: [created, edited, deleted]
+ # 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 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:
+ inputs:
+ pr_number:
+ description: Existing pull request number to evaluate
+ required: true
+ type: string
+
+# The gate reads metadata only. It never checks out or runs pull request code.
+permissions:
+ contents: read
+ pull-requests: read
+ issues: read
+ statuses: write
+ 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: __GOVERNANCE_REPO__/pr-governance@__GOVERNANCE_SHA__
+ env:
+ GOVERNANCE_OWNER_ID: ${{ vars.RISK_GATE_OWNER_ID }}
+ GOVERNANCE_OWNER_LOGIN: ${{ vars.RISK_GATE_OWNER }}
+ with:
+ mode: gate
+ record-label: 'true'
+
+ evaluate:
+ name: Evaluate PR governance
+ needs: record_label
+ # A skipped recorder is the normal case for every other event.
+ if: >-
+ always() && needs.record_label.result != 'cancelled' &&
+ (github.event_name != 'issue_comment' ||
+ (github.event.issue.pull_request &&
+ (github.event.comment.user.type == 'Bot' ||
+ contains(github.event.comment.body || '', 'codex-review'))))
+ runs-on: ubuntu-latest
+ 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: 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: Evaluate
+ uses: __GOVERNANCE_REPO__/pr-governance@__GOVERNANCE_SHA__
+ env:
+ GOVERNANCE_OWNER_ID: ${{ vars.RISK_GATE_OWNER_ID }}
+ GOVERNANCE_OWNER_LOGIN: ${{ vars.RISK_GATE_OWNER }}
+ with:
+ mode: gate
+ record-label: 'false'
diff --git a/pr-governance/templates/weekly-secret-scan.yml b/pr-governance/templates/weekly-secret-scan.yml
new file mode 100644
index 0000000..339de27
--- /dev/null
+++ b/pr-governance/templates/weekly-secret-scan.yml
@@ -0,0 +1,38 @@
+name: Weekly secret scan
+
+# Copy to .github/workflows/weekly-secret-scan.yml and replace
+# __GOVERNANCE_REPO__. Requires the SECRET_SCAN_BASELINE_B64 secret: the
+# redacted identities of findings that predate the policy. The scan refuses to
+# run without it, so an empty baseline can never be mistaken for a clean history.
+
+on:
+ schedule:
+ - cron: '23 4 * * 1'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: weekly-secret-scan
+ cancel-in-progress: false
+
+jobs:
+ history:
+ name: Full-history secret scan
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+ - name: Fetch all remote branches
+ run: git fetch origin '+refs/heads/*:refs/remotes/origin/*'
+ - name: Scan history
+ uses: __GOVERNANCE_REPO__/pr-governance@__GOVERNANCE_SHA__
+ env:
+ SECRET_SCAN_BASELINE_B64: ${{ secrets.SECRET_SCAN_BASELINE_B64 }}
+ with:
+ mode: scan
+ full-history: 'true'