diff options
| author | Linus Färnstrand <linus@mullvad.net> | 2025-09-30 12:01:12 +0200 |
|---|---|---|
| committer | Linus Färnstrand <linus@mullvad.net> | 2025-09-30 12:01:12 +0200 |
| commit | 87aa4b8863edbebb114da1a2e4267891a820b188 (patch) | |
| tree | 164f8df4a8a74c8beaf192fc88f9d5e304c36f7c | |
| parent | f97a1e71011857dd0bdd5ee388cf4fc7a829ec49 (diff) | |
| parent | 9af1544c57277e194f60a252a36e14f10cd9d562 (diff) | |
| download | mullvadvpn-87aa4b8863edbebb114da1a2e4267891a820b188.tar.xz mullvadvpn-87aa4b8863edbebb114da1a2e4267891a820b188.zip | |
Merge branch 'add-code-owner-enforcement-workflow'
| -rw-r--r-- | .github/CODEOWNERS | 8 | ||||
| -rw-r--r-- | .github/workflows/code-owner-approval.yml | 137 | ||||
| -rw-r--r-- | .github/workflows/verify-locked-down-signatures.yml | 4 | ||||
| -rw-r--r-- | code-owners.json | 31 |
4 files changed, 179 insertions, 1 deletions
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 5d17bf7430..513a1d3845 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,6 +1,10 @@ # Defining who has to review changes to what files. # Try to keep the entries sorted alphabetically, so they end up in the same order as # they would if you listed the entire repository as a tree. +# +# This enforcement is to protect certain important files from being changed without the approval +# of team leads. For more fine grained code ownership assignments to teams, see +# `/code-owners.json` and `/.github/workflows/code-owner-approval.yml`. # Container images used for building the app are owned by respective team leads and tech lead /building/android-container-image.txt @faern @albin-mullvad @rawa @@ -26,6 +30,10 @@ /ci/verify-locked-down-signatures.sh @faern @raksooo @pinkisemils @rawa /.github/workflows/unicop.yml @faern @raksooo @pinkisemils @rawa +# Our own code ownership mapping and automation must be approved by leads +/.github/workflows/code-owner-approval.yml @faern @raksooo @pinkisemils @rawa +/code-owners.json @faern @raksooo @pinkisemils @rawa + # The CODEOWNERS itself must be protected from unauthorized changes, # otherwise the protection becomes quite moot. # Keep this entry last, so it is sure to override any existing previous wildcard match diff --git a/.github/workflows/code-owner-approval.yml b/.github/workflows/code-owner-approval.yml new file mode 100644 index 0000000000..96c3868e82 --- /dev/null +++ b/.github/workflows/code-owner-approval.yml @@ -0,0 +1,137 @@ +--- +name: Code Owner Approval +description: Ensure that someone from each team that owns code changed in the PR has approved the PR +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + pull_request_review: + +jobs: + check-team-approvals: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + 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. + github-token: ${{ secrets.CODE_OWNERSHIP_CI_TOKEN }} + script: | + const fs = require('fs'); + const path = require('path'); + + // 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; + } + + + 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'); + return; + } + + console.log(`👥 This PR needs approval from: ${[...affectedTeams].join(', ')}`); + + // Set of teams that have approved this PR + const approvedTeams = new Set(); + + const approvers = await getApprovers(); + console.log(`👍 PR approved by: ${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(', ')}`); + core.setFailed(`Missing approvals from: ${missingApprovals.join(', ')}`); + } else { + console.log('✅ All code owners approved this change!'); + } diff --git a/.github/workflows/verify-locked-down-signatures.yml b/.github/workflows/verify-locked-down-signatures.yml index 157012fd3d..5c965607d7 100644 --- a/.github/workflows/verify-locked-down-signatures.yml +++ b/.github/workflows/verify-locked-down-signatures.yml @@ -3,14 +3,16 @@ name: Verify git signatures on important files on: pull_request: paths: - - .github/workflows/verify-locked-down-signatures.yml - .github/workflows/android-audit.yml + - .github/workflows/code-owner-approval.yml - .github/workflows/unicop.yml + - .github/workflows/verify-locked-down-signatures.yml - .github/CODEOWNERS - Cargo.toml - test/Cargo.toml - Cargo.lock - test/Cargo.lock + - code-owners.json - deny.toml - test/deny.toml - mullvad-ios/deny.toml diff --git a/code-owners.json b/code-owners.json new file mode 100644 index 0000000000..bddcbe5608 --- /dev/null +++ b/code-owners.json @@ -0,0 +1,31 @@ +{ + "appteam-desktop": [ + "building/Dockerfile", + "building/linux-container-image.txt", + "ci/**", + "!ci/buildserver-build-android.sh", + "!ci/ios/**", + "desktop/**", + "installer-downloader/**", + "mullvad-*/**", + "!mullvad-ios/**", + "rustfmt.toml", + "rust-toolchain.toml", + "talpid-*/**", + "test/**", + "tunnel-obfuscation/**", + "windows/**", + "windows-installer/**", + "wireguard-go-rs/**" + ], + "appteam-ios": [ + "ci/ios/**", + "ios/**", + "mullvad-ios/**" + ], + "appteam-android": [ + "android/**", + "building/android-container-image.txt", + "ci/buildserver-build-android.sh" + ] +} |
