Files
2026-06-04 16:24:36 +02:00

186 lines
7.8 KiB
YAML

---
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
permissions: {}
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@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Check code owner approvals
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
# 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.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number
});
return changedFiles.map(file => file.filename);
}
// Returns a list of usernames who approved the PR (based on their latest review)
async function getApprovers() {
const reviews = await github.paginate(github.rest.pulls.listReviews, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number
});
const latestReviews = new Map();
for (const review of reviews) {
const currentLatest = latestReviews.get(review.user.id);
// Keep the most recent review (higher ID = more recent)
if (!currentLatest || review.id > currentLatest.id) {
latestReviews.set(review.user.id, review);
}
}
// Filter to only approved reviews
const approvers = [];
for (const [userId, review] of latestReviews) {
if (review.state === 'APPROVED') {
approvers.push(review.user.login);
}
}
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);
// 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();
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();
// 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}`);
}
}
}
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(', ')}`);
// Set of teams that have approved this PR
const approvedTeams = new Set();
// Get array of github usernames that have approved the PR
const approvers = await getApprovers();
// PR author automatically counts as an approver. A code owner changing
// their own code does not need extra code owner approval. They still
// need the code reviewed, but that's not part of code ownership approval.
const prAuthor = context.payload.pull_request.user.login;
if (!approvers.includes(prAuthor)) {
approvers.push(prAuthor);
}
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})`);
}
}
}
console.log('👍 Teams that have approved this PR:', [...approvedTeams].join(', '));
const missingApprovals = [...affectedTeams].filter(t => !approvedTeams.has(t));
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;
}