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
39 changes: 35 additions & 4 deletions .github/actions/release-approval-guard/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Release approval guard

Fails a release when someone who approved its environment authored or merged a PR in the push that triggered the run.
Fails a release when someone who approved its environment authored or queued a PR in the push that triggered the run. In `report` mode it only names who is excluded, so the approval request can say so.

This restores what GitHub's "Prevent self-review" environment setting did before merge queues. That setting excludes the run's triggering actor. When a person merged a PR, the actor was that person, so they couldn't approve the release. With a merge queue (GitHub or Trunk), the actor is a bot, so nobody is excluded.

Expand All @@ -14,7 +14,7 @@ jobs:
environment: 'NPM Release'
permissions:
contents: read
actions: read # approvals of this run
actions: read # approvals of this run, `enforce` mode only
pull-requests: read # PRs in the push
steps:
- uses: PostHog/.github/.github/actions/release-approval-guard@<sha>
Expand All @@ -24,13 +24,44 @@ jobs:

It needs no checkout. It needs Node, which GitHub-hosted runners include.

## Inputs

| Input | Default | Purpose |
| -------------- | ---------------- | -------------------------------------------------------------------------------------------- |
| `environment` | | Environment whose approval gates the release. Required in `enforce` mode. |
| `mode` | `enforce` | `enforce` fails the job on a blocked approval. `report` never fails and only writes outputs. |
| `github-token` | `github.token` | Token for the GitHub API. |

## Report mode

Use `mode: report` before the approval exists, for example in the job that posts the approval request to Slack:

```yaml
- id: guard
uses: PostHog/.github/.github/actions/release-approval-guard@<sha>
with:
mode: report
```

It lists the PRs in the push and who can't approve them. It never calls the approvals API, so it needs only `contents: read` and `pull-requests: read`, not `actions: read`. On any error it logs a warning and writes empty outputs. Both modes write these outputs, empty when the event isn't a `push`:

| Output | Example |
| ---------------- | --------------------------------------------------------------- |
| `blocked_logins` | `alice,bob` |
| `blocked_mrkdwn` | `<https://github.com/alice\|alice> (queued #5273), <https://github.com/bob\|bob> (author of #5270)` |
| `violations_mrkdwn` | `<https://github.com/alice\|alice> (queued #5273)` (approvers who violated the rule; empty in `report` mode and when none did) |
| `prs_mrkdwn` | `<https://github.com/PostHog/posthog-js/pull/5273\|#5273> fix: flush queue` (one PR per line) |
| `prs` | `5270,5273` |

Titles are escaped for Slack (`&`, `<`, `>`). `prs_mrkdwn` is capped to fit a Slack field. Bots such as the merge queue are left out of the blocked list.

## Behavior

- It finds every PR in the push that triggered the run (`before...after`). A merge queue can merge several PRs in one push.
- It blocks approval by each PR's author and by whoever merged it. With GitHub's merge queue, `merged_by` is the person who added the PR to the queue. With Trunk, it's `trunk-io[bot]`, so only the author is checked.
- It blocks approval by each PR's author and by whoever queued it. With GitHub's merge queue, `merged_by` is the person who added the PR to the queue. With Trunk, it's `trunk-io[bot]`, so only the author is checked.
- On events other than `push`, it does nothing, because GitHub's own check already excludes whoever triggered the run.
- It fails closed if the run has no approval for the environment, if a commit in the push isn't part of a merged PR, or if the push range can't be listed in full.
- The approvals API doesn't say which run attempt an approval belongs to, so every approval in the run counts. After a block, a different approver must approve a **new run**. A re-run can still see the blocked approval.
- After a block, re-run the failed jobs. That requests a new approval, and a different approver must give it. The approvals endpoint then lists only the new approval, so the guard passes on the next attempt.
- Like the setting it replaces, it only covers the PRs in the triggering push. If a release also publishes earlier unreleased changes, their authors aren't excluded.

Run the tests with `node --test .github/actions/release-approval-guard/guard.test.mjs`.
32 changes: 28 additions & 4 deletions .github/actions/release-approval-guard/action.yaml
Original file line number Diff line number Diff line change
@@ -1,22 +1,46 @@
name: 'Release approval guard'
description: 'Fail a release when an approver of its environment authored or merged a PR in the push that triggered it'
description: 'Fail a release when an approver of its environment authored or queued a PR in the push that triggered it, or report who is excluded'

inputs:
environment:
description: 'Name of the environment whose approval gates the release, e.g. "NPM Release"'
required: true
description: 'Name of the environment whose approval gates the release, e.g. "NPM Release". Required in `enforce` mode.'
required: false
default: ''
mode:
description: '`enforce` fails the job when an approver authored or queued a PR in the push. `report` only lists the PRs and who is excluded, never fails, and needs no approval to exist yet.'
required: false
default: 'enforce'
github-token:
description: 'Token with `actions: read`, `contents: read` and `pull-requests: read` on the repository'
description: 'Token with `contents: read` and `pull-requests: read` on the repository, plus `actions: read` in `enforce` mode'
required: false
default: ${{ github.token }}

outputs:
blocked_logins:
description: 'Comma-separated logins excluded from approving: authors and enqueuers of the PRs in the push, bots omitted'
value: ${{ steps.guard.outputs.blocked_logins }}
blocked_mrkdwn:
description: 'Slack mrkdwn list of the excluded logins with the reason, e.g. `<https://github.com/alice|alice> (queued #5273)`'
value: ${{ steps.guard.outputs.blocked_mrkdwn }}
violations_mrkdwn:
description: 'Slack mrkdwn list of the approvers who violated the rule, same format as `blocked_mrkdwn`. Empty in `report` mode and when there are none.'
value: ${{ steps.guard.outputs.violations_mrkdwn }}
prs_mrkdwn:
description: 'Slack mrkdwn list of the PRs in the push, one `<url|#number> title` per line'
value: ${{ steps.guard.outputs.prs_mrkdwn }}
prs:
description: 'Comma-separated numbers of the PRs in the push'
value: ${{ steps.guard.outputs.prs }}

runs:
using: 'composite'
steps:
- name: Check release approvers against the PRs in the push
id: guard
shell: bash
env:
GH_TOKEN: ${{ inputs.github-token }}
ENVIRONMENT: ${{ inputs.environment }}
MODE: ${{ inputs.mode }}
ACTION_PATH: ${{ github.action_path }}
run: node "$ACTION_PATH/guard.mjs"
161 changes: 118 additions & 43 deletions .github/actions/release-approval-guard/guard.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/usr/bin/env node
import { appendFileSync, readFileSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { fileURLToPath } from 'node:url';

export class GuardError extends Error {}
Expand All @@ -26,7 +27,6 @@ export function createApi({ token, apiUrl = 'https://api.github.com', fetchImpl
};
}

// Approvals aren't tied to a run attempt, so every approval in the run counts.
export async function approversFor(api, repo, runId, environment) {
const approvals = await api.get(`/repos/${repo}/actions/runs/${runId}/approvals`);
const logins = approvals
Expand All @@ -44,84 +44,159 @@ export async function pushedPrs(api, repo, before, after) {
throw new GuardError(`Cannot list all ${compare.total_commits} commits between ${before} and ${after}.`);
}
const numbers = new Set();
for (const commit of compare.commits) {
const merged = (await api.get(`/repos/${repo}/commits/${commit.sha}/pulls`)).filter((pr) => pr.merged_at);
const mergedPerCommit = await Promise.all(
compare.commits.map(async (commit) => ({
sha: commit.sha,
merged: (await api.get(`/repos/${repo}/commits/${commit.sha}/pulls`)).filter((pr) => pr.merged_at),
})),
);
for (const { sha, merged } of mergedPerCommit) {
if (merged.length === 0) {
throw new GuardError(`Commit ${commit.sha} in the push does not belong to a merged PR.`);
throw new GuardError(`Commit ${sha} in the push does not belong to a merged PR.`);
}
for (const pr of merged) numbers.add(pr.number);
}
return Promise.all([...numbers].sort((a, b) => a - b).map((n) => api.get(`/repos/${repo}/pulls/${n}`)));
}

export async function checkReleaseApproval({ api, repo, runId, environment, before, after }) {
const approvers = await approversFor(api, repo, runId, environment);
if (approvers.length === 0) {
throw new GuardError(
`No approval for the "${environment}" environment in this run. ` +
'Check the environment input and that the environment has required reviewers.',
);
}
const prs = await pushedPrs(api, repo, before, after);
export function blockedApprovers(prs) {
const blocked = new Map();
const block = (login, reason) => {
if (!login) return;
if (!login || login.endsWith('[bot]')) return;
const key = login.toLowerCase();
if (!blocked.has(key)) blocked.set(key, []);
blocked.get(key).push(reason);
if (!blocked.has(key)) blocked.set(key, { login, reasons: [] });
blocked.get(key).reasons.push(reason);
};
for (const pr of prs) {
block(pr.user?.login, `author of #${pr.number}`);
block(pr.merged_by?.login, `merged #${pr.number}`);
block(pr.merged_by?.login, `queued #${pr.number}`);
}
const violations = approvers
.filter((login) => blocked.has(login.toLowerCase()))
.map((login) => ({ login, reasons: blocked.get(login.toLowerCase()) }));
return { approvers, prs: prs.map((pr) => pr.number), violations };
return [...blocked.values()].sort((a, b) => a.login.toLowerCase().localeCompare(b.login.toLowerCase()));
}

async function main() {
const env = process.env;
if (env.GITHUB_EVENT_NAME !== 'push') {
console.log(
`Not a push (${env.GITHUB_EVENT_NAME}), so GitHub's "Prevent self-review" already excludes whoever triggered the run.`,
const escapeMrkdwn = (text) => text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');

export function blockedMrkdwn(blocked) {
return blocked
.map(({ login, reasons }) => `<https://github.com/${login}|${login}> (${reasons.join(', ')})`)
.join(', ');
}

// Slack caps a section field at 2000 characters.
export function prsMrkdwn(prs, maxLength = 1800) {
const lines = [];
let length = 0;
for (const pr of prs) {
const line = `<${pr.html_url}|#${pr.number}> ${escapeMrkdwn(pr.title ?? '')}`.trimEnd();
if (length + line.length > maxLength) {
lines.push(`…and ${prs.length - lines.length} more`);
break;
}
lines.push(line);
length += line.length + 1;
}
return lines.join('\n');
}

export function guardOutputs(prs, blocked, violations = []) {
return {
blocked_logins: blocked.map((b) => b.login).join(','),
blocked_mrkdwn: blockedMrkdwn(blocked),
violations_mrkdwn: blockedMrkdwn(violations),
prs_mrkdwn: prsMrkdwn(prs),
prs: prs.map((pr) => pr.number).join(','),
};
}

const EMPTY_OUTPUTS = guardOutputs([], []);

export function writeOutputs(file, outputs) {
if (!file) return;
for (const [name, value] of Object.entries(outputs)) {
const delimiter = `ghadelimiter_${randomUUID()}`;
appendFileSync(file, `${name}<<${delimiter}\n${value}\n${delimiter}\n`);
}
}

export async function checkReleaseApproval({ api, repo, runId, environment, before, after }) {
const approvers = await approversFor(api, repo, runId, environment);
if (approvers.length === 0) {
throw new GuardError(
`No approval for the "${environment}" environment in this run. ` +
'Check the environment input and that the environment has required reviewers.',
);
return;
}
const pullRequests = await pushedPrs(api, repo, before, after);
const blocked = blockedApprovers(pullRequests);
const approverSet = new Set(approvers.map((login) => login.toLowerCase()));
const violations = blocked.filter((b) => approverSet.has(b.login.toLowerCase()));
return { approvers, pullRequests, blocked, violations };
}

function pushContext(env) {
const { before, after } = JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, 'utf8'));
return { api: createApi({ token: env.GH_TOKEN, apiUrl: env.GITHUB_API_URL }), repo: env.GITHUB_REPOSITORY, before, after };
}

async function runReport(env) {
try {
const { api, repo, before, after } = pushContext(env);
const prs = await pushedPrs(api, repo, before, after);
const outputs = guardOutputs(prs, blockedApprovers(prs));
writeOutputs(env.GITHUB_OUTPUT, outputs);
console.log(`PRs in the push: ${outputs.prs}`);
console.log(`Excluded from approving: ${outputs.blocked_logins || 'none'}`);
} catch (e) {
console.log(`::warning title=Release approval guard::${e.message}`);
writeOutputs(env.GITHUB_OUTPUT, EMPTY_OUTPUTS);
}
}

async function runEnforce(env) {
const environment = env.ENVIRONMENT?.trim();
if (!environment) throw new GuardError('The environment input is required.');
const event = JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, 'utf8'));

const result = await checkReleaseApproval({
api: createApi({ token: env.GH_TOKEN, apiUrl: env.GITHUB_API_URL }),
repo: env.GITHUB_REPOSITORY,
runId: env.GITHUB_RUN_ID,
environment,
before: event.before,
after: event.after,
});
const { api, repo, before, after } = pushContext(env);

console.log(`PRs in the push: ${result.prs.map((n) => `#${n}`).join(', ')}`);
const result = await checkReleaseApproval({ api, repo, runId: env.GITHUB_RUN_ID, environment, before, after });
writeOutputs(env.GITHUB_OUTPUT, guardOutputs(result.pullRequests, result.blocked, result.violations));

console.log(`PRs in the push: ${result.pullRequests.map((p) => `#${p.number}`).join(', ')}`);
console.log(`Approvers of "${environment}": ${result.approvers.join(', ')}`);

if (result.violations.length > 0) {
const lines = result.violations.map((v) => `${v.login} (${v.reasons.join(', ')})`);
console.log(
`::error title=Release approved by its author::${lines.join('; ')} approved a release ` +
'triggered by their own PR. Another approver must approve a new run of this ' +
'workflow. A re-run of this run can still see the earlier approval and fail again.',
'triggered by their own PR. Re-run the failed jobs to request a new approval, ' +
'and have a different approver approve it.',
);
if (env.GITHUB_STEP_SUMMARY) {
appendFileSync(
env.GITHUB_STEP_SUMMARY,
`### Release blocked\n\nApproved by the author or merger of a PR in this push:\n\n${lines
`### Release blocked\n\nApproved by the author or enqueuer of a PR in this push:\n\n${lines
.map((l) => `- ${l}`)
.join('\n')}\n`,
.join('\n')}\n\nRe-run the failed jobs to request a new approval. A different approver must approve it.\n`,
);
}
process.exit(1);
}
console.log('✓ No approver authored or merged a PR in this push.');
console.log('✓ No approver authored or queued a PR in this push.');
}

async function main() {
const env = process.env;
const mode = env.MODE?.trim() || 'enforce';
if (mode !== 'enforce' && mode !== 'report') {
throw new GuardError(`Unknown mode "${mode}". Use "enforce" or "report".`);
}
if (env.GITHUB_EVENT_NAME !== 'push') {
console.log(
`Not a push (${env.GITHUB_EVENT_NAME}), so GitHub's "Prevent self-review" already excludes whoever triggered the run.`,
);
writeOutputs(env.GITHUB_OUTPUT, EMPTY_OUTPUTS);
return;
}
await (mode === 'report' ? runReport(env) : runEnforce(env));
}

if (process.argv[1] === fileURLToPath(import.meta.url)) {
Expand Down
Loading
Loading