diff options
Diffstat (limited to '.github/workflows')
| -rw-r--r-- | .github/workflows/code-owner-approval.yml | 141 |
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; } |
