Skip to content
Merged
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
21 changes: 21 additions & 0 deletions .github/workflows/governance-drift.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions VERSION
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3
27 changes: 22 additions & 5 deletions pr-governance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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.
Binary file not shown.
34 changes: 34 additions & 0 deletions pr-governance/scripts/check-twin-drift.sh
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions pr-governance/scripts/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand Down
24 changes: 24 additions & 0 deletions pr-governance/scripts/test-main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand All @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions pr-governance/templates/risk-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Loading