Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/pr-governance-tests.yml
Original file line number Diff line number Diff line change
@@ -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
68 changes: 68 additions & 0 deletions pr-governance/.gitleaks.toml
Original file line number Diff line number Diff line change
@@ -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)(?:^|<!\[CDATA\[)[\t ]*(?:export[\t ]+)?["']?(?:[\w.-]*(?:secret|token|password|passwd|appsecret|jwt)[\w.-]*|[\w.-]*access[_-]?key)["']?[\t ]*[:=][\t ]*["']?([^\s"']{8,}?)(?:["'\t \r\n]|\]\]>|$)'''
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_.-]+$''']
125 changes: 125 additions & 0 deletions pr-governance/README.md
Original file line number Diff line number Diff line change
@@ -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`.
92 changes: 92 additions & 0 deletions pr-governance/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
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"
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 }}
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
24 changes: 24 additions & 0 deletions pr-governance/policy.default.json
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading