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
116 changes: 105 additions & 11 deletions .github/workflows/risk-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,80 @@ permissions:
actions: write

jobs:
# The `labeled` payload is the only authenticated record of which head a
# clearing label was added to. It is written here, in its own job with no
# concurrency group, because an evaluation can be superseded by a later event
# and the evidence must not depend on that run reaching its next step.
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: Publish the label's head as an immutable commit status
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
APPROVAL_OWNER: ${{ vars.RISK_GATE_OWNER }}
with:
script: |
const label = context.payload.label?.name;
if (!['owner-approved', 'agent-approved'].includes(label)) return;
// Only the configured approver's label carries a claim.
if (!process.env.APPROVAL_OWNER || context.payload.sender?.login !== process.env.APPROVAL_OWNER) return;
const sha = context.payload.pull_request?.head?.sha;
// A closed pull request keeps no authority over a commit that
// another pull request may now use.
if (!sha || context.payload.pull_request?.state !== 'open') return;
// The pull request is part of the record: two PRs can share a
// commit, and an approval given on one must not clear the other.
const number = context.payload.pull_request?.number;
if (!number) return;
// The event id is part of the record too. A label can be revoked
// and added again later; without the id, moving the head back would
// revive the revoked approval, because the record and the live
// event would not be the same approval.
//
// The id has to come from this delivery, not from the newest event
// at execution time: a run queued for head A can start after the
// label was revoked and added again on head B, and taking the
// newest event would stamp B's approval onto A. The payload carries
// no event id, but a label change bumps `pull_request.updated_at`
// to the addition's own time, and that snapshot arrived with the
// head it records, so matching on that time names this addition.
const addedAt = context.payload.pull_request?.updated_at;
if (!addedAt) return;
const events = await github.paginate(github.rest.issues.listEvents, {
...context.repo, issue_number: number, per_page: 100,
});
const matches = events.filter(event => event.event === 'labeled'
&& event.label?.name === label
&& event.actor?.login === process.env.APPROVAL_OWNER
&& event.created_at === addedAt);
// Two additions in the same second cannot be told apart; recording
// either could credit the wrong head, so this delivery records
// nothing and the gate stays closed until a later addition.
if (matches.length !== 1) return;
const record = matches[0];
await github.rest.repos.createCommitStatus({
...context.repo, sha, context: 'clearing-label', state: 'success',
description: `${label} for PR ${number} at event ${record.id} on this head`,
});

evaluate:
name: Evaluate PR governance
if: github.event_name != 'issue_comment' || (github.event.issue.pull_request && (github.event.comment.user.login == 'chatgpt-codex-connector[bot]' || github.event.comment.user.login == 'chatgpt-codex-connector'))
needs: record_label
# A skipped recorder is the normal case for every other event.
if: always() && needs.record_label.result != 'cancelled'
&& (github.event_name != 'issue_comment' || (github.event.issue.pull_request && (github.event.comment.user.login == 'chatgpt-codex-connector[bot]' || github.event.comment.user.login == 'chatgpt-codex-connector')))
runs-on: ubuntu-latest
timeout-minutes: 5
# Job-level, so a comment from anyone else skips the job without cancelling
# an evaluation already running for this PR.
# an evaluation already running for this PR. Label evidence no longer rides
# on this run, so a superseded evaluation costs nothing.
concurrency:
group: risk-gate-${{ github.event.pull_request.number || github.event.issue.number || inputs.pr_number }}
cancel-in-progress: true
Expand Down Expand Up @@ -193,11 +260,13 @@ jobs:
if (!commits.length || commits.length >= 250) return false;
// `author.login` is the account GitHub links from the commit email,
// and the author name is free text; a human can set either. A
// verified signature only proves some key signed it, so the
// committer must be GitHub itself. One commit outside that shape
// sends the PR down the normal review path.
// verified signature only proves some key signed it, so the rest
// of the shape has to match a commit GitHub itself made for the
// bot: committer `web-flow` from `noreply@github.com`. This is the
// same identity `scripts/dependabot_auto_merge_gate.py` accepts.
return commits.every(commit => commit.author?.login === 'dependabot[bot]'
&& commit.committer?.login === 'web-flow'
&& commit.commit?.committer?.email === 'noreply@github.com'
&& commit.commit?.verification?.verified === true);
}
// The dependency policy names the workflows whose action updates may
Expand Down Expand Up @@ -282,7 +351,7 @@ jobs:
// pushing onto a bot branch must not inherit the skip.
const isDependabot = pr.user?.login === 'dependabot[bot]' && await dependabotHead(number);
const actionFiles = isDependabot ? await eligibleActionFiles(pr.base.ref) : null;
await status('risk-gate', 'pending', 'Evaluating changed paths, owner approval and AI review');
await status('risk-gate', 'pending', 'Evaluating changed paths, clearing label and AI review');
await status('pr-template', 'pending', 'Checking AI, change and verification fields');
await status('ai-review', 'pending', 'Reading the Codex review receipt');
const files = await github.paginate(github.rest.pulls.listFiles, { ...repo, pull_number: number, per_page: 100 });
Expand All @@ -301,18 +370,43 @@ jobs:
// description always names which one applied, so an agent-cleared
// high-risk merge is visible instead of passing as a human review.
const APPROVAL_LABELS = ['owner-approved', 'agent-approved'];
// A label lives on the pull request, not on a commit, so a push
// after the label leaves it looking current. The `record-label` job
// writes the head from the `labeled` event to a commit status, and
// this evaluation credits the label only when the current head
// carries that record. Commit dates cannot prove which revision was
// current, and a status is keyed by the SHA it names.
const CLEARING_RECORD = 'clearing-label';
let approval = null;
if (pr.labels.some(label => APPROVAL_LABELS.includes(label.name))) {
const events = await github.paginate(github.rest.issues.listEvents, { ...repo, issue_number: number, per_page: 100 });
const liveLabel = name => {
const latest = events.filter(event => ['labeled', 'unlabeled'].includes(event.event)
&& event.label?.name === name).sort((a, b) => b.id - a.id)[0];
return latest?.event === 'labeled' && APPROVAL_OWNER !== ''
&& latest.actor?.login === APPROVAL_OWNER;
&& latest.actor?.login === APPROVAL_OWNER ? latest : null;
};
// A human confirmation outranks an agent's when both are present.
if (liveLabel('owner-approved')) approval = 'owner';
else if (liveLabel('agent-approved')) approval = 'agent';
// Only a label bound to this head counts, so a stale owner label
// cannot shadow a fresh agent label: each label is checked on its
// own and owner precedence applies after that, never before.
const onHead = {};
const live = APPROVAL_LABELS.map(name => [name, liveLabel(name)]).filter(([, event]) => event);
if (live.length) {
const statuses = await github.paginate(github.rest.repos.listCommitStatusesForRef,
{ ...repo, ref: sha, per_page: 100 });
for (const [name, event] of live) {
// The record must name the same event that is live now, so a
// revoked approval is not revived by an older record.
if (statuses.some(entry => entry.context === CLEARING_RECORD
&& entry.state === 'success'
&& entry.description === `${name} for PR ${number} at event ${event.id} on this head`)) {
onHead[name] = true;
}
}
}
// A human confirmation outranks an agent's when both are on this head.
if (onHead['owner-approved']) approval = 'owner';
else if (onHead['agent-approved']) approval = 'agent';
}
const { data: current } = await github.rest.pulls.get({ ...repo, pull_number: number });
if (current.head.sha !== sha || current.body !== pr.body
Expand All @@ -338,7 +432,7 @@ jobs:
const tierNote = !risky ? 'Low-risk paths: no owner approval needed'
: approval === 'owner' ? 'High-risk paths: owner-approved label verified'
: approval === 'agent' ? 'High-risk paths: cleared by agent-approved, NOT confirmed by the owner'
: 'High-risk paths: needs-owner-approval; owner must add owner-approved';
: 'High-risk paths: needs a clearing label; owner-approved or agent-approved';
let gateState;
let gateNote;
if (!tierOK) {
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ Organization documentation rules are checked by `python scripts/check_style.py`,
- Commit and PR titles use professional English, imperative Conventional Commits (`feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `perf`, `ci`) and at most 72 characters. Sentence case is allowed; commitlint ignores merge commits.
- PR descriptions explain the problem, changes, verification and risk. AI-authored PRs identify the tool and the model when available. Labels and approval or risk summaries use professional English.
- Use `risk:low` for documentation, tests and ordinary checks; use `risk:high` and `needs-owner-approval` for protected paths. Classification labels describe risk; the path-based `risk-gate` determines whether approval is required.
- AI-only development uses required automated checks, template validation and the `ai-review` context published from the Codex receipt on the current head, with zero required human or code-owner approvals. High-risk paths require a clearing label: `owner-approved` states that the owner confirmed the change; `agent-approved` states that an agent cleared it, and the `risk-gate` description then says `NOT confirmed by the owner`. Agents must never add or remove `owner-approved`.
- AI-only development uses required automated checks, template validation and the `ai-review` context published from the Codex receipt on the current head; no human or code-owner approval is required, and no change waits on a human label. High-risk paths require one clearing label, and either label clears `risk-gate`: once the Codex receipt on that head is completed with no unresolved findings and every required context except the label-dependent `risk-gate` is green, an agent may add `agent-approved` itself, record the addition in the ledger, and then wait for `risk-gate` to re-evaluate and pass; the status description then says `NOT confirmed by the owner`. `owner-approved` stays available when the owner chooses to confirm a change personally, and agents must never add or remove it. A review or label that is not on the current head clears nothing.
- Preserve required checks and branch protection; merge only through normal checks. Use `<type>/<topic>` PR branches; never push directly to the default branch or force-push a reviewed branch. Commit, push, merge and production actions require authorization; existing authorization remains valid within its scope.

## Scope and authorization
Expand Down
Loading