diff --git a/README.md b/README.md index 2f58449..70a6976 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Infrastructure as Code for managing access to MCP community resources using Pulu ## What This Manages - **GitHub Teams**: Automatically syncs team memberships in the MCP GitHub organization +- **GitHub Repositories**: Creates and owns repositories declared with `settings` in [`src/config/repoAccess.ts`](src/config/repoAccess.ts), and syncs team and user access for every listed repository. See [Creating a new repository](#creating-a-new-repository-working-group-leads) below. - **Google Workspace Groups**: Automatically syncs group memberships for @modelcontextprotocol.io email accounts - **Email Groups**: Groups with `isEmailGroup: true` accept emails from anyone (including external users) and notify all members. External posts are moderated for security. - **Google Workspace User Accounts**: Provisions @modelcontextprotocol.io accounts for members of roles with `provisionUser: true` (directly, or via a role nested under one through `github.parent` — e.g. SDK teams under `sdk-maintainers`, working groups under `working-groups`) @@ -32,6 +33,34 @@ If you're a maintainer — explicitly or implicitly (SDK maintainers, working gr Once merged, Pulumi provisions the account. An admin will share your initial password (retrievable via `pulumi stack output --show-secrets newGWSUserPasswords`). +### Creating a new repository (working group leads) + +Org members cannot create repositories directly. Instead, open a PR adding an entry to [`src/config/repoAccess.ts`](src/config/repoAccess.ts) with a `settings` block plus the usual `teams`: + +```ts +{ + repository: 'ext-example', + settings: { + description: 'MCP Extension for Example. Maintained by the Example Working Group.', + // visibility: 'public' (default), homepage, topics, template are optional + }, + teams: [ + { team: 'core-maintainers', permission: 'admin' }, + { team: 'moderators', permission: 'maintain' }, + { team: 'example-wg', permission: 'admin' }, + ], +}, +``` + +The PR's `pulumi preview` comment shows the repository create. Once merged, the deploy creates the repository (with the baseline in `REPOSITORY_DEFAULTS`) and then grants the listed access, in one apply. Notes: + +- Repository names are lowercase kebab-case (`ext-*` for extensions, `experimental-ext-*` while experimental). +- At least one team or user must have `admin` permission (validated), so a managed repository is never ownerless. +- Removing the entry **archives** the repository rather than deleting it; deletion stays a manual org-owner action. +- Entries without `settings` are access-only: the repository pre-dates this config and Pulumi manages only its collaborators. Adding `settings` to such an entry does not adopt the repository — the deploy fails with a name-already-exists error. Adopt it with `pulumi import` first; that is out of scope for the PR flow above. +- The `repository` key of a managed entry is also the Pulumi resource name. Renaming it in place archives the old repository and creates a new one: rename on GitHub first, then move the state (`pulumi state mv`) before changing the key. +- A repository archived by hand in GitHub stays archived (`archived` is ignored on refresh); un-archiving is a manual org-owner action. + ## Cloudflare Access (security-room) [securityroom.modelcontextprotocol.io](https://securityroom.modelcontextprotocol.io) is protected by Cloudflare Zero Trust Access with GitHub as the identity provider. The reusable Access policy `Maintainers` that grants sign-in is managed from this repo by [`src/cloudflare.ts`](src/cloudflare.ts), driven by [`src/config/accessPolicies.ts`](src/config/accessPolicies.ts): diff --git a/scripts/test-config.ts b/scripts/test-config.ts index c3320e7..576edb6 100644 --- a/scripts/test-config.ts +++ b/scripts/test-config.ts @@ -18,6 +18,7 @@ import { NPM_DEFAULT_POLICY, } from '../src/config/packageAccess'; import { ACCESS_POLICIES, getAccessPolicyTeams } from '../src/config/accessPolicies'; +import { REPOSITORY_ACCESS, REPOSITORY_DEFAULTS } from '../src/config/repoAccess'; let passed = 0; let failed = 0; @@ -133,6 +134,22 @@ test('Some members in provisionUser roles have Google user fields', () => { return membersInProvisionRoles.length > 0 && provisioned.length > 0; }); +// Test repository config +test('REPOSITORY_ACCESS has no duplicate repositories', () => { + const names = REPOSITORY_ACCESS.map((r) => r.repository); + return names.length > 0 && names.length === new Set(names).size; +}); +test('All managed repositories (with settings) have an admin grant', () => + REPOSITORY_ACCESS.filter((r) => r.settings).every( + (r) => + r.teams?.some((t) => t.permission === 'admin') || + r.users?.some((u) => u.permission === 'admin') + )); +test('All managed repositories have a non-empty description', () => + REPOSITORY_ACCESS.filter((r) => r.settings).every((r) => !!r.settings!.description.trim())); +test('REPOSITORY_DEFAULTS archives instead of deleting on destroy', () => + REPOSITORY_DEFAULTS.archiveOnDestroy === true); + // Test package registry access config test('NPM_ORG is modelcontextprotocol', () => NPM_ORG === 'modelcontextprotocol'); test('NPM_PACKAGES is not empty and all packages are org-scoped', () => diff --git a/scripts/validate-config.ts b/scripts/validate-config.ts index 4b64fb6..58014bc 100644 --- a/scripts/validate-config.ts +++ b/scripts/validate-config.ts @@ -50,6 +50,56 @@ for (const repo of REPOSITORY_ACCESS) { } } +// Validate repository entries in REPOSITORY_ACCESS +console.log('Validating repository entries in repoAccess.ts...'); +{ + const repositoryNames = new Set(); + for (const repo of REPOSITORY_ACCESS) { + if (repositoryNames.has(repo.repository)) { + console.error(`ERROR: Repository "${repo.repository}" is declared twice in repoAccess.ts`); + hasErrors = true; + } + repositoryNames.add(repo.repository); + } + + // Managed repositories (entries with `settings`) are created by Pulumi, so + // their inputs must be valid before the deploy tries to create them. + for (const repo of REPOSITORY_ACCESS) { + if (!repo.settings) continue; + + if (!/^[a-z0-9][a-z0-9._-]*$/.test(repo.repository)) { + console.error( + `ERROR: Managed repository "${repo.repository}" has an invalid name; use lowercase letters, digits, '.', '_' and '-'` + ); + hasErrors = true; + } + + if (!repo.settings.description.trim()) { + console.error(`ERROR: Managed repository "${repo.repository}" has an empty description`); + hasErrors = true; + } + + const hasAdmin = + (repo.teams?.some((t) => t.permission === 'admin') ?? false) || + (repo.users?.some((u) => u.permission === 'admin') ?? false); + if (!hasAdmin) { + console.error( + `ERROR: Managed repository "${repo.repository}" grants no team or user admin permission; ` + + `a managed repository must have an admin so it is never ownerless` + ); + hasErrors = true; + } + + if (repo.settings.template && !repositoryNames.has(repo.settings.template)) { + console.error( + `ERROR: Managed repository "${repo.repository}" uses template "${repo.settings.template}" ` + + `which is not declared in repoAccess.ts; templates must be known org repositories` + ); + hasErrors = true; + } + } +} + // Validate role references in MEMBERS (memberOf) console.log('Validating role references in users.ts...'); for (const member of MEMBERS) { diff --git a/src/config/repoAccess.ts b/src/config/repoAccess.ts index 67a8e60..1415b93 100644 --- a/src/config/repoAccess.ts +++ b/src/config/repoAccess.ts @@ -1,18 +1,85 @@ -// Repository access configuration -// Each repository lists all teams and users that should have access and their permission level +// Repository configuration +// Each entry lists all teams and users that should have access to a repository +// and their permission level. There are two kinds of entries: +// +// - Access-only: the repository pre-dates this config. Pulumi manages only its +// collaborators; the repository itself is untouched. +// - Managed: the entry has `settings`. Pulumi creates the repository (if it does +// not exist yet), keeps those settings in sync, and then manages its +// collaborators — so a new repository and its access land in one deploy. +// +// To create a new repository (e.g. for a working group), add a managed entry. +// See README.md "Creating a new repository". + +export type RepositoryPermission = 'pull' | 'triage' | 'push' | 'maintain' | 'admin'; + +/** + * Settings for a repository that Pulumi creates and owns. Declaring this on an + * entry makes the deploy create the repository (if it does not exist yet) and + * keep these fields in sync afterwards. Removing the entry ARCHIVES the + * repository rather than deleting it (archiveOnDestroy); actual deletion stays + * a manual org-owner action. + */ +export interface RepositorySettings { + /** Shown on the repository page and in the org listing. Required. */ + description: string; + /** Defaults to 'public'. */ + visibility?: 'public' | 'private'; + homepage?: string; + topics?: readonly string[]; + /** Create from a template repository in the org (applied at creation only). */ + template?: string; +} export interface RepositoryAccess { repository: string; + /** + * Declare to have Pulumi create and own the repository. Omit for repositories + * that pre-date this config (access-only). Adding `settings` to a repository + * that already exists does not adopt it: the deploy fails with a + * name-already-exists error. Adopt it with `pulumi import` first. The + * `repository` key of a managed entry is also the Pulumi resource name, so + * renaming it in place archives the old repository and creates a new one: + * rename on GitHub first, then move the state (`pulumi state mv`) before + * changing the key. + */ + settings?: RepositorySettings; teams?: Array<{ team: string; // Team slug - permission: 'pull' | 'triage' | 'push' | 'maintain' | 'admin'; + permission: RepositoryPermission; }>; users?: Array<{ username: string; // GitHub username - permission: 'pull' | 'triage' | 'push' | 'maintain' | 'admin'; + permission: RepositoryPermission; }>; } +/** + * Baseline applied to every repository Pulumi creates. Mirrors how the org's + * existing extension repositories (ext-*, experimental-ext-*) are configured; + * per-repo fields come from RepositorySettings. Security features (Dependabot, + * secret scanning, ...) come from orgSettings.ts "*EnabledForNewRepositories" + * and are not repeated here. + */ +export const REPOSITORY_DEFAULTS = { + hasIssues: true, + hasProjects: false, + hasWiki: false, + hasDiscussions: false, + allowSquashMerge: true, + allowMergeCommit: false, + allowRebaseMerge: false, + allowUpdateBranch: true, + deleteBranchOnMerge: true, + squashMergeCommitTitle: 'PR_TITLE', + squashMergeCommitMessage: 'PR_BODY', + vulnerabilityAlerts: true, + autoInit: true, + licenseTemplate: 'apache-2.0', + // Removing a managed entry archives the repository instead of deleting it. + archiveOnDestroy: true, +} as const; + export const REPOSITORY_ACCESS: RepositoryAccess[] = [ { repository: 'docs', diff --git a/src/github.ts b/src/github.ts index b7100ea..a744f35 100644 --- a/src/github.ts +++ b/src/github.ts @@ -1,7 +1,8 @@ import * as pulumi from '@pulumi/pulumi'; import * as github from '@pulumi/github'; import { ROLES, type Role, buildRoleLookup } from './config/roles'; -import { REPOSITORY_ACCESS } from './config/repoAccess'; +import { REPOSITORY_ACCESS, REPOSITORY_DEFAULTS } from './config/repoAccess'; +import { GITHUB_ORG } from './config/accessPolicies'; import { ORG_ROLE_ASSIGNMENTS } from './config/orgRoles'; import { ORG_SETTINGS } from './config/orgSettings'; import { MEMBERS } from './config/users'; @@ -97,11 +98,42 @@ ORG_ROLE_ASSIGNMENTS.forEach((assignment) => { // @pulumi/github release that includes that fix. const orgRoleTeamNames = [...new Set(ORG_ROLE_ASSIGNMENTS.map((a) => a.team))]; -// Configure repository access +// Repositories. An entry with `settings` is created and owned by Pulumi; the +// collaborators resource then depends on it through the name output, so a new +// repository and its access land in the same apply. Entries without `settings` +// pre-date this config: Pulumi manages only their collaborators. +const repositories: Record = {}; REPOSITORY_ACCESS.forEach((repo) => { + let repositoryName: pulumi.Input = repo.repository; + if (repo.settings) { + const repository = new github.Repository( + `repository-${repo.repository}`, + { + ...REPOSITORY_DEFAULTS, + name: repo.repository, + description: repo.settings.description, + visibility: repo.settings.visibility ?? 'public', + homepageUrl: repo.settings.homepage, + topics: repo.settings.topics ? [...repo.settings.topics] : undefined, + template: repo.settings.template + ? { owner: GITHUB_ORG, repository: repo.settings.template } + : undefined, + }, + { + // A repository archived by hand in GitHub must stay archived: without + // this, the deploy's `pulumi up --refresh` would plan archived: true -> + // false and un-archive it. Archiving through this config still works + // (remove the entry; archiveOnDestroy archives instead of deleting). + ignoreChanges: ['archived'], + } + ); + repositories[repo.repository] = repository; + repositoryName = repository.name; + } + const grantedTeams = new Set(repo.teams?.map((t) => t.team)); new github.RepositoryCollaborators(`repo-${repo.repository}`, { - repository: repo.repository, + repository: repositoryName, // Ignore org-role teams, except where repoAccess.ts grants them directly on // this repository (e.g. lead-maintainers on maintainer-docs) — those grants // must stay managed by Pulumi. @@ -119,4 +151,4 @@ REPOSITORY_ACCESS.forEach((repo) => { }); }); -export { teams as githubTeams }; +export { teams as githubTeams, repositories as githubRepositories };