summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorLinus Färnstrand <linus@mullvad.net>2025-09-30 12:01:12 +0200
committerLinus Färnstrand <linus@mullvad.net>2025-09-30 12:01:12 +0200
commit87aa4b8863edbebb114da1a2e4267891a820b188 (patch)
tree164f8df4a8a74c8beaf192fc88f9d5e304c36f7c
parentf97a1e71011857dd0bdd5ee388cf4fc7a829ec49 (diff)
parent9af1544c57277e194f60a252a36e14f10cd9d562 (diff)
downloadmullvadvpn-87aa4b8863edbebb114da1a2e4267891a820b188.tar.xz
mullvadvpn-87aa4b8863edbebb114da1a2e4267891a820b188.zip
Merge branch 'add-code-owner-enforcement-workflow'
-rw-r--r--.github/CODEOWNERS8
-rw-r--r--.github/workflows/code-owner-approval.yml137
-rw-r--r--.github/workflows/verify-locked-down-signatures.yml4
-rw-r--r--code-owners.json31
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"
+ ]
+}