summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/code-owner-approval.yml141
1 files changed, 90 insertions, 51 deletions
diff --git a/.github/workflows/code-owner-approval.yml b/.github/workflows/code-owner-approval.yml
index 511e49037a..a66b074210 100644
--- a/.github/workflows/code-owner-approval.yml
+++ b/.github/workflows/code-owner-approval.yml
@@ -2,27 +2,54 @@
name: Code Owner Approval
description: Ensure that someone from each team that owns code changed in the PR has approved the PR
on:
+ # Trigger on both changes to the PR content and new/changed reviews. Both of these can affect
+ # whether the PR has the required approvals.
+ pull_request:
pull_request_review:
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
jobs:
check-code-owner-approvals:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
+ # Required to write a custom commit status with createCommitStatus
+ statuses: write
steps:
- uses: actions/checkout@v5
- name: Check code owner approvals
uses: actions/github-script@v8
with:
- # Requires a token with read access to the "members" scope under organization,
- # and read access to the pull request scope under the repository.
+ # Requires a token with read access to the "Members" scope under organization,
+ # and read access to the "Pull request" scope under the repository.
+ # and write access to "Commit statuses" under the repository.
github-token: ${{ secrets.CODE_OWNERSHIP_CI_TOKEN }}
script: |
const fs = require('fs');
const path = require('path');
+ // Helper function to set a custom commit status. This is not the status
+ // of this workflow run, but rather a custom status.
+ // This is required since the `pull_request` and `pull_request_review` events
+ // trigger independently and create separate statuses, that can be incompatible.
+ // Instead we report both of these into a custom status, that is set as required
+ // in the branch protection rules.
+ async function setCommitStatus(state, description) {
+ await github.rest.repos.createCommitStatus({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ sha: context.payload.pull_request.head.sha,
+ state: state,
+ context: 'Code Owner Approval',
+ description: description
+ });
+ }
+
// Returns an array of file paths changed in the PR
async function getChangedFiles() {
const changedFiles = await github.rest.pulls.listFiles({
@@ -64,72 +91,84 @@ jobs:
return approvers;
}
+ // Put all logic in a try-catch, so we can set the commit status
+ // to "error" if something fails.
+ try {
+ // Set status to pending at the start, clearing any previous state
+ await setCommitStatus('pending', 'Checking code owner approvals...');
- const changedFiles = await getChangedFiles();
- console.log('[DEBUG] Files changed in this PR:', changedFiles);
+ const changedFiles = await getChangedFiles();
+ console.log('[DEBUG] Files changed in this PR:', changedFiles);
- // Load team ownership mapping
- const codeOwnerships = JSON.parse(fs.readFileSync('code-owners.json'));
+ // Load team ownership mapping
+ const codeOwnerships = JSON.parse(fs.readFileSync('code-owners.json'));
- // The set of teams owning code changed in this PR
- const affectedTeams = new Set();
+ // The set of teams owning code changed in this PR
+ const affectedTeams = new Set();
- for (const [team, patterns] of Object.entries(codeOwnerships)) {
- console.log(`[DEBUG] Team: ${team}, Ownership patterns:`, [...patterns]);
+ for (const [team, patterns] of Object.entries(codeOwnerships)) {
+ console.log(`[DEBUG] Team: ${team}, Ownership patterns:`, [...patterns]);
- // List all files in the repository matching this owner's patterns
- const globber = await glob.create(patterns.join('\n'));
- const matches = await globber.glob();
+ // List all files in the repository matching this owner's patterns
+ const globber = await glob.create(patterns.join('\n'));
+ const matches = await globber.glob();
- // Convert absolute paths to relative paths
- const ownedFiles = matches.map(match =>
- path.relative(process.env.GITHUB_WORKSPACE, match)
- );
+ // Convert absolute paths to relative paths
+ const ownedFiles = matches.map(match =>
+ path.relative(process.env.GITHUB_WORKSPACE, match)
+ );
- for (const changedFile of changedFiles) {
- if (ownedFiles.includes(changedFile)) {
- affectedTeams.add(team);
- console.log(`[DEBUG] File ${changedFile} is owned by ${team}`);
+ for (const changedFile of changedFiles) {
+ if (ownedFiles.includes(changedFile)) {
+ affectedTeams.add(team);
+ console.log(`[DEBUG] File ${changedFile} is owned by ${team}`);
+ }
}
}
- }
- if (affectedTeams.size === 0) {
- console.log('✅ No code owner for any changed file');
- return;
- }
+ if (affectedTeams.size === 0) {
+ console.log('✅ No code owner for any changed file');
+ await setCommitStatus('success', 'No changes require code owner approval');
+ return;
+ }
- console.log(`👥 This PR needs approval from: ${[...affectedTeams].join(', ')}`);
+ console.log(`👥 This PR needs approval from: ${[...affectedTeams].join(', ')}`);
- // Set of teams that have approved this PR
- const approvedTeams = new Set();
+ // Set of teams that have approved this PR
+ const approvedTeams = new Set();
- const approvers = await getApprovers();
- console.log(`👍 PR approved by: ${approvers.join(', ')}`);
+ const approvers = await getApprovers();
+ console.log(`👍 PR approved by the following accounts: ${approvers.join(', ')}`);
- for (const approver of approvers) {
- for (const team of affectedTeams) {
- try {
- await github.rest.teams.getMembershipForUserInOrg({
- org: context.repo.owner,
- team_slug: team,
- username: approver
- });
- approvedTeams.add(team);
- console.log(`[DEBUG] ${approver} is member of team '${team}' - approval counted`);
- } catch (e) {
- console.log(`[DEBUG] ${approver} is not member of team '${team} (${e})`);
+ for (const approver of approvers) {
+ for (const team of affectedTeams) {
+ try {
+ await github.rest.teams.getMembershipForUserInOrg({
+ org: context.repo.owner,
+ team_slug: team,
+ username: approver
+ });
+ approvedTeams.add(team);
+ console.log(`[DEBUG] ${approver} is member of team '${team}' - approval counted`);
+ } catch (e) {
+ console.log(`[DEBUG] ${approver} is not member of team '${team} (${e})`);
+ }
}
}
- }
- console.log('👍 Teams that have approved this PR:', [...approvedTeams].join(', '));
+ console.log('👍 Teams that have approved this PR:', [...approvedTeams].join(', '));
- const missingApprovals = [...affectedTeams].filter(t => !approvedTeams.has(t));
+ const missingApprovals = [...affectedTeams].filter(t => !approvedTeams.has(t));
- if (missingApprovals.length > 0) {
- console.log(`❌ Missing approvals from: ${missingApprovals.join(', ')}`);
- core.setFailed(`Missing approvals from: ${missingApprovals.join(', ')}`);
- } else {
- console.log('✅ All code owners approved this change!');
+ if (missingApprovals.length > 0) {
+ console.log(`❌ Missing approvals from: ${missingApprovals.join(', ')}`);
+ await setCommitStatus('failure', `Missing approvals from: ${missingApprovals.join(', ')}`);
+ } else {
+ console.log('✅ All code owners approved this change!');
+ await setCommitStatus('success', `All code owners have approved: ${[...affectedTeams].join(', ')}`);
+ }
+ } catch (error) {
+ console.error('Error checking code owner approvals:', error);
+ await setCommitStatus('error', 'Error checking approvals');
+ throw error;
}