--- 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. # 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({ owner: context.repo.owner, repo: context.repo.repo, pull_number: context.issue.number }); return changedFiles.data.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.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.data) { 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; }