From 6fb2f2d8678685324cd4cf732a9fd3c703c90822 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 18:39:39 +0000 Subject: [PATCH 1/2] Let repoAccess.ts entries create and own repositories A repository entry in src/config/repoAccess.ts can now be one of two kinds: - Access-only (unchanged): the repository pre-dates this config and Pulumi manages only its collaborators. Every existing entry stays this kind. - Managed: the entry carries a `settings` block (description, optional visibility/homepage/topics/template). Pulumi creates the repository from REPOSITORY_DEFAULTS plus those settings, then manages its collaborators, so a new repository and its access land in one deploy. Working group leads can create a repository by opening a PR that adds one entry. Removing a managed entry archives the repository rather than deleting it (archiveOnDestroy). Validation rejects duplicate repository names and, for managed entries, invalid names, empty descriptions, missing admin grants and templates that are not declared org repositories. This commit declares no managed repository, so it produces no resource changes. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Kuh5eR5uDFfknR1vRPEkfn --- README.md | 27 +++++++++++++++ scripts/test-config.ts | 17 ++++++++++ scripts/validate-config.ts | 50 +++++++++++++++++++++++++++ src/config/repoAccess.ts | 69 +++++++++++++++++++++++++++++++++++--- src/github.ts | 30 ++++++++++++++--- 5 files changed, 185 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 2f58449..f329de5 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,32 @@ 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. Adopting an existing repository requires a `pulumi import` and is out of scope for the PR flow above. + ## 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..ccc109f 100644 --- a/src/config/repoAccess.ts +++ b/src/config/repoAccess.ts @@ -1,18 +1,79 @@ -// 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). + */ + 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..21cea67 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,32 @@ 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, + }); + 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 +141,4 @@ REPOSITORY_ACCESS.forEach((repo) => { }); }); -export { teams as githubTeams }; +export { teams as githubTeams, repositories as githubRepositories }; From 8afda049757166fecc0ae6ebaaf210a83d88d3ce Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 19:42:49 +0000 Subject: [PATCH 2/2] Keep manually archived repositories archived; document renames The deploy runs `pulumi up --refresh`, so a managed repository archived by hand in GitHub would be refreshed to archived=true and then planned back to false, un-archiving it. Ignore `archived` on the Repository resource so the manual state sticks; archiving via this config (removing the entry, which archiveOnDestroy turns into an archive) is unaffected. Also document that adding `settings` to an existing repository does not adopt it (import first), and that the `repository` key is the Pulumi resource name, so renaming it in place archives the old repository. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Kuh5eR5uDFfknR1vRPEkfn --- README.md | 4 +++- src/config/repoAccess.ts | 8 +++++++- src/github.ts | 32 +++++++++++++++++++++----------- 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index f329de5..70a6976 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,9 @@ The PR's `pulumi preview` comment shows the repository create. Once merged, the - 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. Adopting an existing repository requires a `pulumi import` and is out of scope for the PR flow above. +- 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) diff --git a/src/config/repoAccess.ts b/src/config/repoAccess.ts index ccc109f..1415b93 100644 --- a/src/config/repoAccess.ts +++ b/src/config/repoAccess.ts @@ -35,7 +35,13 @@ export interface RepositoryAccess { repository: string; /** * Declare to have Pulumi create and own the repository. Omit for repositories - * that pre-date this config (access-only). + * 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<{ diff --git a/src/github.ts b/src/github.ts index 21cea67..a744f35 100644 --- a/src/github.ts +++ b/src/github.ts @@ -106,17 +106,27 @@ 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, - }); + 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; }