diff --git a/.github/workflows/governance-drift.yml b/.github/workflows/governance-drift.yml new file mode 100644 index 0000000..f19c4c7 --- /dev/null +++ b/.github/workflows/governance-drift.yml @@ -0,0 +1,21 @@ +name: Governance drift + +on: + schedule: + - cron: '23 3 * * 1' + workflow_dispatch: + +permissions: + contents: read + +jobs: + drift: + name: drift + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Compare the twin repositories + run: sh pr-governance/scripts/check-twin-drift.sh diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +3 diff --git a/pr-governance/README.md b/pr-governance/README.md index b21cae3..025ed62 100644 --- a/pr-governance/README.md +++ b/pr-governance/README.md @@ -23,6 +23,10 @@ A pull request may merge when all of these hold: Branch protection, not this action, is the merge constraint. The action only publishes evidence; it cannot merge, label, or write repository contents. +The gate applies to repositories that build, deploy or run code. A repository +that only publishes a static site through a `pages` workflow keeps that +deployment workflow and is exempt from the gate. + ## Risk tiers | Tier | Typical paths | Requirement | @@ -64,7 +68,10 @@ organization community-health files. `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. + variables and replaces `owner` when they are set. A repository whose content + rules keep accounts out of public source may also leave `reviewer` out and + set `RISK_GATE_REVIEWER_APP_ID`, `RISK_GATE_REVIEWER_USER_ID` and + `RISK_GATE_REVIEWER_LOGIN` instead; a partial set fails closed. 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 @@ -82,7 +89,8 @@ organization community-health files. 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. +variables are set, but no account identity is required in the file. The +reviewer follows the same rule through the `RISK_GATE_REVIEWER_*` variables. ## Trust model @@ -120,6 +128,15 @@ 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`. +repositories, and hosted Actions may be billing-blocked. Private repositories +use a local enforcement track instead: Git hooks that reject staged credentials, +unconventional commit headers, direct pushes to a protected branch, and +non-fast-forward pushes, plus an audit over the tracked tree. + + +## Twin repositories + +`pr-governance/` is byte-identical in `Pigbibi/governance`, `GdeiAssistant/.github` +and `QuantStrategyLab/.github`; `VERSION` names the shared revision. Each +repository runs `scripts/check-twin-drift.sh` weekly and on demand. A change to +any shared file is applied to all three before the drift check can pass. diff --git a/pr-governance/scripts/__pycache__/secret_scan.cpython-313.pyc b/pr-governance/scripts/__pycache__/secret_scan.cpython-313.pyc deleted file mode 100644 index 856784b..0000000 Binary files a/pr-governance/scripts/__pycache__/secret_scan.cpython-313.pyc and /dev/null differ diff --git a/pr-governance/scripts/check-twin-drift.sh b/pr-governance/scripts/check-twin-drift.sh new file mode 100755 index 0000000..d892b20 --- /dev/null +++ b/pr-governance/scripts/check-twin-drift.sh @@ -0,0 +1,34 @@ +#!/bin/sh +# Compare the shared governance content across the three twin repositories. +set -eu + +REPOS="Pigbibi/governance GdeiAssistant/.github QuantStrategyLab/.github" +base=$(cd "$(dirname "$0")/../.." && pwd) +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +status=0 + +for repo in $REPOS; do + slug=$(printf '%s' "$repo" | tr '/' '_') + mkdir -p "$tmp/$slug" + archive="$tmp/$slug.tar.gz" + if ! curl --fail --silent --show-error --location --retry 2 \ + "https://codeload.github.com/$repo/tar.gz/refs/heads/main" --output "$archive"; then + echo "cannot download $repo" >&2 + status=1 + continue + fi + if ! tar -xzf "$archive" -C "$tmp/$slug" --strip-components=1; then + echo "cannot extract $repo" >&2 + status=1 + continue + fi + for path in pr-governance VERSION; do + if ! diff -ru -x '__pycache__' "$base/$path" "$tmp/$slug/$path"; then + echo "drift: $path differs from $repo" >&2 + status=1 + fi + done +done + +exit $status diff --git a/pr-governance/scripts/main.cjs b/pr-governance/scripts/main.cjs index 22c3e1b..f1ba8ce 100644 --- a/pr-governance/scripts/main.cjs +++ b/pr-governance/scripts/main.cjs @@ -19,6 +19,23 @@ function configuredOwner() { return { user_id: id, login }; } +// The reviewer is the same kind of account identity, so a repository that keeps +// accounts out of public source can configure it the same way. A partial set +// fails closed instead of falling back to a committed identity. +function configuredReviewer() { + const appId = process.env.GOVERNANCE_REVIEWER_APP_ID; + const userId = process.env.GOVERNANCE_REVIEWER_USER_ID; + const login = process.env.GOVERNANCE_REVIEWER_LOGIN; + if (!appId && !userId && !login) return null; + const app = Number(appId); + const user = Number(userId); + if (!Number.isSafeInteger(app) || app < 1 || !Number.isSafeInteger(user) || user < 1 + || typeof login !== 'string' || !login) { + throw new Error('Incomplete reviewer configuration'); + } + return { provider: process.env.GOVERNANCE_REVIEWER_PROVIDER || 'codex', app_id: app, user_id: user, login }; +} + async function defaultBranch(github, context) { const known = context.payload?.repository?.default_branch || context.payload?.pull_request?.base?.ref; @@ -45,6 +62,8 @@ async function loadPolicy(github, context, branch) { } const owner = configuredOwner(); if (owner) policy.owner = owner; + const reviewer = configuredReviewer(); + if (reviewer) policy.reviewer = reviewer; 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. diff --git a/pr-governance/scripts/test-main.cjs b/pr-governance/scripts/test-main.cjs index ef16177..f71db46 100644 --- a/pr-governance/scripts/test-main.cjs +++ b/pr-governance/scripts/test-main.cjs @@ -36,6 +36,9 @@ async function load(policy, environment) { } finally { delete process.env.GOVERNANCE_OWNER_ID; delete process.env.GOVERNANCE_OWNER_LOGIN; + delete process.env.GOVERNANCE_REVIEWER_APP_ID; + delete process.env.GOVERNANCE_REVIEWER_USER_ID; + delete process.env.GOVERNANCE_REVIEWER_LOGIN; Object.assign(process.env, saved); } } @@ -57,6 +60,27 @@ test('a policy without owner and without variables fails closed', async () => { await assert.rejects(load(POLICY, {})); }); +const OWNER_ENV = { GOVERNANCE_OWNER_ID: '42', GOVERNANCE_OWNER_LOGIN: 'owner' }; +const REVIEWER_ENV = { ...OWNER_ENV, GOVERNANCE_REVIEWER_APP_ID: '7', + GOVERNANCE_REVIEWER_USER_ID: '8', GOVERNANCE_REVIEWER_LOGIN: 'reviewer[bot]' }; + +test('reviewer configuration replaces a committed reviewer', async () => { + const { policy } = await load(POLICY, REVIEWER_ENV); + assert.deepEqual(policy.reviewer, { provider: 'codex', app_id: 7, user_id: 8, login: 'reviewer[bot]' }); +}); + +test('a policy without reviewer is valid when the variables are set', async () => { + const { reviewer, ...rest } = POLICY; + const { policy } = await load(rest, REVIEWER_ENV); + assert.deepEqual(policy.reviewer, { provider: 'codex', app_id: 7, user_id: 8, login: 'reviewer[bot]' }); +}); + +test('an incomplete reviewer configuration fails closed', async () => { + await assert.rejects(load(POLICY, { GOVERNANCE_REVIEWER_APP_ID: '7' }), /reviewer configuration/); + await assert.rejects(load(POLICY, { ...REVIEWER_ENV, GOVERNANCE_REVIEWER_USER_ID: '0' }), + /reviewer configuration/); +}); + test('an incomplete owner configuration fails closed', async () => { await assert.rejects(load(POLICY, { GOVERNANCE_OWNER_ID: '42' }), /owner configuration/); await assert.rejects(load(POLICY, diff --git a/pr-governance/templates/risk-gate.yml b/pr-governance/templates/risk-gate.yml index 1c68191..8a1a7af 100644 --- a/pr-governance/templates/risk-gate.yml +++ b/pr-governance/templates/risk-gate.yml @@ -88,6 +88,11 @@ jobs: env: GOVERNANCE_OWNER_ID: ${{ vars.RISK_GATE_OWNER_ID }} GOVERNANCE_OWNER_LOGIN: ${{ vars.RISK_GATE_OWNER }} + # Optional: a repository that keeps account identifiers out of public + # source configures the reviewer here instead of in policy.json. + GOVERNANCE_REVIEWER_APP_ID: ${{ vars.RISK_GATE_REVIEWER_APP_ID }} + GOVERNANCE_REVIEWER_USER_ID: ${{ vars.RISK_GATE_REVIEWER_USER_ID }} + GOVERNANCE_REVIEWER_LOGIN: ${{ vars.RISK_GATE_REVIEWER_LOGIN }} with: mode: gate record-label: 'false'