From 21cec890f65bba7370dad2de1bbadf5aa4711282 Mon Sep 17 00:00:00 2001 From: Victor Accarini Date: Tue, 12 May 2026 12:39:19 -0300 Subject: [PATCH] workflows: add dashboard team management file To add and remove members of the dashboard team we use a specific membership file, this way it's easier to onboarding and offboard new members. Github has a policy that only members of an organization can be part of a team, this change automates that invitation and team setup. Signed-off-by: Victor Accarini --- .github/dashboard-team | 5 + .github/scripts/package-lock.json | 186 +++++++++++++++++++ .github/scripts/package.json | 8 + .github/scripts/sync-dashboard-team.js | 198 +++++++++++++++++++++ .github/workflows/sync-dashboard-team.yaml | 91 ++++++++++ CODEOWNERS | 5 + 6 files changed, 493 insertions(+) create mode 100644 .github/dashboard-team create mode 100644 .github/scripts/package-lock.json create mode 100644 .github/scripts/package.json create mode 100644 .github/scripts/sync-dashboard-team.js create mode 100644 .github/workflows/sync-dashboard-team.yaml create mode 100644 CODEOWNERS diff --git a/.github/dashboard-team b/.github/dashboard-team new file mode 100644 index 000000000..a81b7fc07 --- /dev/null +++ b/.github/dashboard-team @@ -0,0 +1,5 @@ +nuclearcat +bhcopeland +victor-accarini +alanpeixinho +gustavobtflores diff --git a/.github/scripts/package-lock.json b/.github/scripts/package-lock.json new file mode 100644 index 000000000..849d103c7 --- /dev/null +++ b/.github/scripts/package-lock.json @@ -0,0 +1,186 @@ +{ + "name": "automation-scripts", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "automation-scripts", + "dependencies": { + "@octokit/rest": "22.0.1" + } + }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", + "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.3", + "@octokit/request": "^10.0.6", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.3.tgz", + "integrity": "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==", + "dependencies": { + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", + "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", + "dependencies": { + "@octokit/request": "^10.0.6", + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", + "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz", + "integrity": "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz", + "integrity": "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/request": { + "version": "10.0.8", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.8.tgz", + "integrity": "sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw==", + "dependencies": { + "@octokit/endpoint": "^11.0.3", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "fast-content-type-parse": "^3.0.0", + "json-with-bigint": "^3.5.3", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", + "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/rest": { + "version": "22.0.1", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-22.0.1.tgz", + "integrity": "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==", + "dependencies": { + "@octokit/core": "^7.0.6", + "@octokit/plugin-paginate-rest": "^14.0.0", + "@octokit/plugin-request-log": "^6.0.0", + "@octokit/plugin-rest-endpoint-methods": "^17.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, + "node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==" + }, + "node_modules/fast-content-type-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", + "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/json-with-bigint": { + "version": "3.5.8", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.8.tgz", + "integrity": "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==" + }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==" + } + } +} diff --git a/.github/scripts/package.json b/.github/scripts/package.json new file mode 100644 index 000000000..3371e3d95 --- /dev/null +++ b/.github/scripts/package.json @@ -0,0 +1,8 @@ +{ + "name": "automation-scripts", + "private": true, + "type": "commonjs", + "dependencies": { + "@octokit/rest": "22.0.1" + } +} diff --git a/.github/scripts/sync-dashboard-team.js b/.github/scripts/sync-dashboard-team.js new file mode 100644 index 000000000..02957e15c --- /dev/null +++ b/.github/scripts/sync-dashboard-team.js @@ -0,0 +1,198 @@ +#!/usr/bin/env node + +const fs = require("fs"); +const { Octokit } = require("@octokit/rest"); +const path = require("path"); + +const octokit = new Octokit({ + auth: process.env.GITHUB_TOKEN, +}); + +const ORG = process.env.ORG; +const TEAM_SLUG = process.env.TEAM_SLUG; +const DRY_RUN = process.env.DRY_RUN === "true"; + +const TEAM_FILE = path.join( + __dirname, + "..", + "dashboard-team", +); + +const PROTECTED_USERS = ["nuclearcat", "bhcopeland", "victor-accarini"]; + +const USER_RE = /^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/i; + +function validateEnv() { + const required = ["ORG", "TEAM_SLUG", "GITHUB_TOKEN"]; + + const missing = required.filter((v) => !process.env[v]); + + if (missing.length > 0) { + throw new Error(`Missing required env vars: ${missing.join(", ")}`); + } +} + +function readDesiredUsers() { + if (!fs.existsSync(TEAM_FILE)) { + throw new Error(`Team file not found: ${TEAM_FILE}`); + } + + const content = fs.readFileSync(TEAM_FILE, "utf8"); + + const users = [ + ...new Set( + content + .split("\n") + .map((line) => line.trim().toLowerCase()) + .filter((line) => line && !line.startsWith("#")), + ), + ]; + + const invalidUsers = users.filter((u) => !USER_RE.test(u)); + if (invalidUsers.length > 0) { + throw new Error( + `Found invalid GitHub usernames: ${invalidUsers.join(", ")}`, + ); + } + + return users; +} + +async function getCurrentTeamMembers() { + const members = await octokit.paginate(octokit.teams.listMembersInOrg, { + org: ORG, + team_slug: TEAM_SLUG, + per_page: 100, + }); + + return members.map((m) => m.login.toLowerCase()); +} + +async function ensureOrgMembership(username) { + try { + const membership = await octokit.orgs.getMembershipForUser({ + org: ORG, + username, + }); + + const state = membership.data.state; + + if (state === "active") { + console.log(`${username} is already an active org member`); + return "active"; + } + + if (state === "pending") { + console.log(`${username} already has a pending org invitation`); + return "pending"; + } + + throw new Error(`Unexpected membership state for ${username}: ${state}`); + } catch (err) { + if (err.status === 404) { + if (DRY_RUN) { + console.log(`[DRY RUN] Would invite ${username} to org`); + return "invited"; + } + + console.log(`Inviting ${username} to org`); + + await octokit.orgs.setMembershipForUser({ + org: ORG, + username, + role: "direct_member", + }); + + return "invited"; + } + + throw err; + } +} + +async function addToTeam(username) { + if (DRY_RUN) { + console.log(`[DRY RUN] Would add ${username} to ${TEAM_SLUG}`); + return; + } + + console.log(`Adding ${username} to ${TEAM_SLUG}`); + + await octokit.teams.addOrUpdateMembershipForUserInOrg({ + org: ORG, + team_slug: TEAM_SLUG, + username, + role: "member", + }); +} + +async function removeFromTeam(username) { + if (PROTECTED_USERS.includes(username)) { + console.log(`Skipping protected user removal: ${username}`); + return; + } + + if (DRY_RUN) { + console.log(`[DRY RUN] Would remove ${username} from ${TEAM_SLUG}`); + return; + } + + console.log(`Removing ${username} from ${TEAM_SLUG}`); + + await octokit.teams.removeMembershipForUserInOrg({ + org: ORG, + team_slug: TEAM_SLUG, + username, + }); +} + +async function main() { + validateEnv(); + + const desiredUsers = readDesiredUsers(); + const currentUsers = await getCurrentTeamMembers(); + + const usersToAdd = desiredUsers.filter((u) => !currentUsers.includes(u)); + const usersToRemove = currentUsers.filter((u) => !desiredUsers.includes(u)); + + console.log(""); + console.log("=== Dashboard Team Sync ==="); + console.log(""); + + console.log(`Desired users (${desiredUsers.length}):`); + desiredUsers.forEach((u) => console.log(` - ${u}`)); + + console.log(""); + + console.log(`Current users (${currentUsers.length}):`); + currentUsers.forEach((u) => console.log(` - ${u}`)); + + console.log(""); + + for (const username of usersToAdd) { + const membershipState = await ensureOrgMembership(username); + + if (membershipState !== "active") { + console.log(`${username} is not yet an active org member`); + + continue; + } + + await addToTeam(username); + } + + for (const username of usersToRemove) { + await removeFromTeam(username); + } + + console.log(""); + console.log("Sync complete"); +} + +main().catch((err) => { + console.error(""); + console.error("Sync failed"); + console.error(err); + + process.exit(1); +}); diff --git a/.github/workflows/sync-dashboard-team.yaml b/.github/workflows/sync-dashboard-team.yaml new file mode 100644 index 000000000..831532edb --- /dev/null +++ b/.github/workflows/sync-dashboard-team.yaml @@ -0,0 +1,91 @@ +name: Sync Dashboard Team + +on: + push: + branches: + - main + paths: + - ".github/dashboard-team" + - ".github/scripts/sync-dashboard-team.js" + - ".github/scripts/package.json" + - ".github/scripts/package-lock.json" + - ".github/workflows/sync-dashboard-team.yaml" + + pull_request: + paths: + - ".github/dashboard-team" + - ".github/scripts/sync-dashboard-team.js" + - ".github/scripts/package.json" + - ".github/scripts/package-lock.json" + - ".github/workflows/sync-dashboard-team.yaml" + +concurrency: + group: sync-dashboard-team + cancel-in-progress: false + +permissions: + contents: read + +jobs: + # Add validation checks for Pull Requests + validate: + if: github.event_name == 'pull_request' && secrets.ORG_ADMIN_TOKEN != '' + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: .github/scripts/package-lock.json + + - name: Install dependencies + working-directory: .github/scripts + run: npm ci + + - name: Dry run + working-directory: .github/scripts + run: node sync-dashboard-team.js + env: + DRY_RUN: true + GITHUB_TOKEN: ${{ secrets.ORG_ADMIN_TOKEN }} + ORG: kernelci + TEAM_SLUG: dashboard + + # Sync the members with the organization and dashboard-team + sync: + if: github.event_name == 'push' + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: .github/scripts/package-lock.json + + - name: Install dependencies + working-directory: .github/scripts + run: npm ci + + - name: Sync Dashboard Team + working-directory: .github/scripts + run: node sync-dashboard-team.js + env: + GITHUB_TOKEN: ${{ secrets.ORG_ADMIN_TOKEN }} + ORG: kernelci + TEAM_SLUG: dashboard diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 000000000..d516baf9e --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,5 @@ +# Access-control file +.github/scripts/ @kernelci/admins +.github/workflows/ @kernelci/admins + +.github/dashboard-team @kernelci/admins @victor-accarini