diff --git a/README.md b/README.md index 6ae7ad2..8cb1475 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ Source: [`docs/architecture.mmd`](docs/architecture.mmd). Edit the `.mmd` file a 1. **Load and validate.** [`src/setup/loader.ts`](src/setup/loader.ts) parses each YAML file against a [valibot](https://valibot.dev/) schema in [`src/types/`](src/types/), merges member records from [`config/members.yaml`](config/members.yaml) into team definitions ([`src/setup/members.ts`](src/setup/members.ts)), then runs cross-reference checks ([`src/setup/validate.ts`](src/setup/validate.ts)): unknown team/repo references, branch patterns claimed by multiple rulesets, and labels defined in multiple groups. 2. **Resolve.** [`src/setup/resolve.ts`](src/setup/resolve.ts) fills each repo from org-wide `defaults` in [`config/org.yaml`](config/org.yaml) and translates config into Pulumi inputs. -3. **Provision.** [`src/org.ts`](src/org.ts) creates teams (when `enableTeams` is on), org rulesets (when `enableRulesets` is on), and one `OrgRepository` component ([`src/resources/repo.ts`](src/resources/repo.ts)) per entry in `repos.yaml`. Each component owns that repo's team access, repository rulesets (when org rulesets are off), optional branch-protection overrides, deployment environments, labels, and synced `.github/CODEOWNERS`. +3. **Provision.** [`src/org.ts`](src/org.ts) creates teams (when `enableTeams` is on), org rulesets (when `enableRulesets` is on), and one `OrgRepository` component ([`src/resources/repo.ts`](src/resources/repo.ts)) per entry in `repos.yaml`. Each component owns that repo's team access, repository rulesets (when org rulesets are off), optional branch-protection overrides, deployment environments, labels, synced `.github/CODEOWNERS`, and a synced `.github/dependabot.yml` for repos that ask for one. Schemas use `strictObject`, so an unknown or misspelled YAML key fails the run instead of being silently ignored. @@ -211,6 +211,8 @@ Set `autoInit: true` when Pulumi should create an empty GitHub repo (GitHub seed **Add a label.** Add it under any group in `config/labels.yaml`. Label names must be unique across groups. +**Give a repo a Dependabot config.** List its ecosystems under `dependabot` in `config/repos.yaml`. The schedule, labels and assignees come from `defaults.dependabot` in `config/org.yaml`, so every synced file behaves the same way. Like CODEOWNERS, the file is committed to the default branch, so validation rejects it on a repo in the reviewed ruleset tier. Add the file in those repos by pull request. + ## Scripts ```sh diff --git a/config/labels.yaml b/config/labels.yaml index 6166a84..ef5b837 100644 --- a/config/labels.yaml +++ b/config/labels.yaml @@ -82,6 +82,9 @@ development: test: color: "9ad857" description: "Test additions or updates" + dependencies: + color: "0366d6" + description: "Updates a dependency" chore: color: "BDC3C7" description: "Housekeeping, non-user-facing maintenance" diff --git a/config/org.yaml b/config/org.yaml index b6f4902..d586bbb 100644 --- a/config/org.yaml +++ b/config/org.yaml @@ -19,6 +19,16 @@ defaults: # does not offer them on private ones. security: dependabotAlerts: true - dependabotSecurityUpdates: false + dependabotSecurityUpdates: true secretScanning: true pushProtection: true + # How a synced .github/dependabot.yml behaves, matching Thaw's. A repo opts + # in by listing its ecosystems under `dependabot` in repos.yaml. + dependabot: + interval: weekly + day: monday + openPullRequestsLimit: 10 + labels: + - dependencies + assignees: + - diazdesandi diff --git a/config/repos.yaml b/config/repos.yaml index 0f4a69f..1d80bb4 100644 --- a/config/repos.yaml +++ b/config/repos.yaml @@ -4,6 +4,9 @@ # Out of scope (manual admin, not listed here): Thaw, prk-bin, shieldcn, # AXSwift6, and platform itself. # +# `adopt: true` imports an existing repo on its first apply and is removed +# afterwards; every repo below is already imported. +# # Branch enforcement is defined in config/rulesets.yaml, which lists the repos # each ruleset is on. A repo on a ruleset that requires status checks names its # own CI checks here (requiredStatusChecks). @@ -22,6 +25,9 @@ repos: - squash deleteBranchOnMerge: true autoInit: true + dependabot: + - bun + - github-actions - name: updates description: >- @@ -61,7 +67,6 @@ repos: - name: brand-assets description: "Shared Thaw brand assets: headers, icons, packaging art, marketing." visibility: public - adopt: true bare: true hasIssues: false hasWiki: false @@ -80,7 +85,6 @@ repos: - squash deleteBranchOnMerge: true autoInit: false - adopt: true - name: raycast-extension description: Control Thaw directly from Raycast. Toggle visibility sections, search menu bar items, and open settings without touching your mouse. @@ -94,7 +98,6 @@ repos: - squash deleteBranchOnMerge: true autoInit: false - adopt: true requiredStatusChecks: - ci @@ -104,7 +107,6 @@ repos: - name: PlatformRuntimeKit description: Proprietary Swift package providing current-platform menu bar runtime support for Thaw. visibility: private - adopt: true bare: true autoInit: false @@ -149,13 +151,10 @@ repos: - squash deleteBranchOnMerge: false autoInit: false - adopt: true bare: true hasWiki: true hasProjects: true hasDiscussions: true - security: - dependabotSecurityUpdates: true codeowners: | * @diazdesandi branchProtection: diff --git a/config/rulesets.yaml b/config/rulesets.yaml index 8165acb..9b01799 100644 --- a/config/rulesets.yaml +++ b/config/rulesets.yaml @@ -33,8 +33,9 @@ rulesets: # branch, so a change to config/codeowners.yaml reaches these repos by pull # request. bypassActors: + # No actorId: GitHub stores none for organization admins, so naming one + # shows as a change on every run. - actorType: OrganizationAdmin - actorId: 1 bypassMode: pull_request - actorType: RepositoryRole actorId: 5 # repository admin @@ -55,9 +56,8 @@ rulesets: requiredLinearHistory: true # Merges are squashed by GitHub, which signs them. requiredSignatures: true - copilotCodeReview: - reviewDraftPullRequests: false - reviewOnPush: false + # No copilotCodeReview: the rule's presence is what requests a Copilot + # review on every pull request. pullRequest: requiredApprovingReviewCount: 1 dismissStaleReviewsOnPush: true diff --git a/config/schema/org.schema.json b/config/schema/org.schema.json index f68a77f..222885e 100644 --- a/config/schema/org.schema.json +++ b/config/schema/org.schema.json @@ -85,6 +85,56 @@ "secretScanning": true, "pushProtection": true } + }, + "dependabot": { + "type": "object", + "properties": { + "interval": { + "enum": ["daily", "weekly", "monthly"], + "type": "string" + }, + "day": { + "enum": [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday" + ], + "type": "string" + }, + "openPullRequestsLimit": { + "type": "integer", + "minimum": 0 + }, + "labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "assignees": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "interval", + "openPullRequestsLimit", + "labels", + "assignees" + ], + "additionalProperties": false, + "default": { + "interval": "weekly", + "openPullRequestsLimit": 5, + "labels": [], + "assignees": [] + } } }, "required": [ diff --git a/config/schema/repos.schema.json b/config/schema/repos.schema.json index 3d6fbab..c56d32c 100644 --- a/config/schema/repos.schema.json +++ b/config/schema/repos.schema.json @@ -104,6 +104,21 @@ "codeowners": { "type": "string" }, + "dependabot": { + "type": "array", + "items": { + "enum": [ + "bun", + "npm", + "github-actions", + "swift", + "bundler", + "pip", + "docker" + ], + "type": "string" + } + }, "requiredStatusChecks": { "type": "array", "items": { diff --git a/src/org.ts b/src/org.ts index b2c8121..58fefef 100644 --- a/src/org.ts +++ b/src/org.ts @@ -6,6 +6,7 @@ import { OrgRepository, } from "@/resources"; import { + assertOrgRulesetsSupported, assertReviewerTeamsCreatable, buildRepoConfig, initConfig, @@ -22,6 +23,7 @@ export default async function setupOrg() { const enableRulesets = pulumiConfig.getBoolean("enableRulesets") ?? true; assertReviewerTeamsCreatable(repos, enableTeams); + if (enableRulesets) assertOrgRulesetsSupported(repos, rulesets); const teamResources = enableTeams ? createTeams(teams) : {}; if (enableTeams) createTeamMemberships(teams, teamResources); diff --git a/src/resources/codeowners.ts b/src/resources/codeowners.ts deleted file mode 100644 index ff50c67..0000000 --- a/src/resources/codeowners.ts +++ /dev/null @@ -1,26 +0,0 @@ -import github from "@pulumi/github"; -import { mergeOptions, type ResourceOptions } from "@pulumi/pulumi"; - -export function createCodeowners( - resourcePrefix: string, - repo: github.Repository, - defaultBranch: string, - content: string, - opts?: ResourceOptions, -): github.RepositoryFile | undefined { - const trimmed = content.trim(); - if (!trimmed) return undefined; - - return new github.RepositoryFile( - `${resourcePrefix}-codeowners`, - { - repository: repo.name, - branch: defaultBranch, - file: ".github/CODEOWNERS", - content: trimmed, - commitMessage: "chore: sync CODEOWNERS from org config", - overwriteOnCreate: true, - }, - mergeOptions(opts, { dependsOn: [repo] }), - ); -} diff --git a/src/resources/files.ts b/src/resources/files.ts new file mode 100644 index 0000000..04c4fcc --- /dev/null +++ b/src/resources/files.ts @@ -0,0 +1,82 @@ +import github from "@pulumi/github"; +import { mergeOptions, type ResourceOptions } from "@pulumi/pulumi"; + +interface SyncedFile { + resourceName: string; + repo: github.Repository; + defaultBranch: string; + file: string; + content: string; + commitMessage: string; +} + +// Commits a file to the repo's default branch. Empty content means the repo +// does not have the file synced. +function createSyncedFile( + { + resourceName, + repo, + defaultBranch, + file, + content, + commitMessage, + }: SyncedFile, + opts?: ResourceOptions, +): github.RepositoryFile | undefined { + const trimmed = content.trim(); + if (!trimmed) return undefined; + + return new github.RepositoryFile( + resourceName, + { + repository: repo.name, + branch: defaultBranch, + file, + content: trimmed, + commitMessage, + overwriteOnCreate: true, + }, + mergeOptions(opts, { dependsOn: [repo] }), + ); +} + +export function createCodeowners( + resourcePrefix: string, + repo: github.Repository, + defaultBranch: string, + content: string, + opts?: ResourceOptions, +): github.RepositoryFile | undefined { + return createSyncedFile( + { + resourceName: `${resourcePrefix}-codeowners`, + repo, + defaultBranch, + file: ".github/CODEOWNERS", + content, + commitMessage: "chore: sync CODEOWNERS from org config", + }, + opts, + ); +} + +export function createDependabot( + resourcePrefix: string, + repo: github.Repository, + defaultBranch: string, + content: string, + opts?: ResourceOptions, +): github.RepositoryFile | undefined { + return createSyncedFile( + { + resourceName: `${resourcePrefix}-dependabot`, + repo, + defaultBranch, + file: ".github/dependabot.yml", + content, + commitMessage: "chore: sync dependabot.yml from org config", + }, + // A repo that stops listing ecosystems keeps the file it has. + mergeOptions(opts, { retainOnDelete: true }), + ); +} diff --git a/src/resources/repo.ts b/src/resources/repo.ts index 3ee370f..5afce01 100644 --- a/src/resources/repo.ts +++ b/src/resources/repo.ts @@ -6,8 +6,8 @@ import { } from "@pulumi/pulumi"; import type { ResolvedRepoConfig, TeamAccess, TeamResourceMap } from "@/types"; import { createBranchProtection } from "./branch"; -import { createCodeowners } from "./codeowners"; import { createEnvironments } from "./environments"; +import { createCodeowners, createDependabot } from "./files"; import { createLabels } from "./labels"; import { createRepositoryRulesets } from "./rulesets"; @@ -22,6 +22,7 @@ type ActiveRepoContext = { security: ResolvedRepoConfig["security"]; defaultBranch: string; codeownersContent: string; + dependabotContent: string; teamResources: TeamResourceMap; }; @@ -159,6 +160,17 @@ function provisionActiveRepoResources( dependsOn: [ctx.repo], }, ); + + createDependabot( + ctx.resourcePrefix, + ctx.repo, + ctx.defaultBranch, + ctx.dependabotContent, + { + parent, + dependsOn: [ctx.repo], + }, + ); } export default class OrgRepository extends ComponentResource { @@ -195,6 +207,7 @@ export default class OrgRepository extends ComponentResource { squashMergeCommitMessage, defaultBranch, codeownersContent, + dependabotContent, } = config; const allowSquashMerge = mergeStrategies.includes("squash"); @@ -249,6 +262,7 @@ export default class OrgRepository extends ComponentResource { security, defaultBranch, codeownersContent, + dependabotContent, teamResources, }); } diff --git a/src/resources/rulesets.ts b/src/resources/rulesets.ts index 5a3ea52..53ef2e0 100644 --- a/src/resources/rulesets.ts +++ b/src/resources/rulesets.ts @@ -12,6 +12,8 @@ function organizationRulesetStatusChecks( ruleset: RulesetConfig, ): github.types.input.OrganizationRulesetRulesRequiredStatusChecks | undefined { const checks = ruleset.rules.requiredStatusChecks; + // acceptAnyOf has no org-level form; assertOrgRulesetsSupported refuses it + // before any resource is created. if (!checks?.enabled || !checks.requiredChecks?.length) return undefined; return { @@ -154,12 +156,10 @@ export function createRepositoryRulesets( opts?: pulumi.ResourceOptions, ): github.RepositoryRuleset[] { return rulesets.map((r) => { - const refIncludes = r.conditions.refName.includes.map( - normalizeRulesetRefName, - ); - const refExcludes = (r.conditions.refName.excludes ?? []).map( - normalizeRulesetRefName, - ); + const refName = (pattern: string) => + normalizeRulesetRefName(pattern, r.target); + const refIncludes = r.conditions.refName.includes.map(refName); + const refExcludes = (r.conditions.refName.excludes ?? []).map(refName); return new github.RepositoryRuleset( `${resourcePrefix}-rs-${r.id}`, diff --git a/src/setup/dependabot.ts b/src/setup/dependabot.ts new file mode 100644 index 0000000..97c4dad --- /dev/null +++ b/src/setup/dependabot.ts @@ -0,0 +1,46 @@ +import type { DependabotDefaults, DependabotEcosystem } from "@/types"; + +// JSON's string form is also a YAML double-quoted scalar, so a quote or a +// backslash in a value cannot break the file. +const list = (key: string, items: string[]) => + items.length + ? [` ${key}:`, ...items.map((i) => ` - ${JSON.stringify(i)}`)] + : []; + +// One update block per ecosystem. Minor and patch bumps arrive as one pull +// request a week; a major bump gets its own, so one breaking upgrade cannot +// hold back the rest. +export function renderDependabot( + ecosystems: DependabotEcosystem[], + defaults: DependabotDefaults, +): string { + if (ecosystems.length === 0) return ""; + + const blocks = ecosystems.map((ecosystem) => + [ + ` - package-ecosystem: "${ecosystem}"`, + ' directory: "/"', + " schedule:", + ` interval: "${defaults.interval}"`, + ...(defaults.day ? [` day: "${defaults.day}"`] : []), + ` open-pull-requests-limit: ${defaults.openPullRequestsLimit}`, + ...list("labels", defaults.labels), + ...list("assignees", defaults.assignees), + " groups:", + ` ${ecosystem}:`, + " patterns:", + ' - "*"', + " update-types:", + ' - "minor"', + ' - "patch"', + ].join("\n"), + ); + + return [ + "# Synced from thaw-app/platform. Edit config/repos.yaml there.", + "version: 2", + "updates:", + blocks.join("\n\n"), + "", + ].join("\n"); +} diff --git a/src/setup/index.ts b/src/setup/index.ts index cf27680..dfe64fa 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -1,3 +1,4 @@ +export * from "./dependabot"; export * from "./loader"; export * from "./resolve"; export * from "./rulesets"; diff --git a/src/setup/resolve.ts b/src/setup/resolve.ts index db6c5a7..19551b1 100644 --- a/src/setup/resolve.ts +++ b/src/setup/resolve.ts @@ -7,6 +7,7 @@ import type { TeamResourceMap, TeamsConfig, } from "@/types"; +import { renderDependabot } from "./dependabot"; import { mergeRepoBranchProtection, rulesetsForRepo, @@ -128,6 +129,10 @@ export function buildRepoConfig( defaultBranch: defaults.defaultBranch, codeownersContent: repo.codeowners ?? (repo.bare ? "" : ctx.codeownersContent), + dependabotContent: renderDependabot( + repo.dependabot ?? [], + defaults.dependabot, + ), pulumiName: repo.pulumiName ?? repo.name, organization, }; diff --git a/src/setup/rulesets.ts b/src/setup/rulesets.ts index 89e5197..2937389 100644 --- a/src/setup/rulesets.ts +++ b/src/setup/rulesets.ts @@ -3,7 +3,7 @@ import type { RepoConfig, RulesetConfig, } from "@/types"; -import { normalizeBranchPattern } from "./utils"; +import { globMatches, normalizeBranchPattern } from "./utils"; type RepoCondition = { includes: string[]; excludes: string[] }; @@ -19,10 +19,9 @@ export function rulesetAppliesToRepo( repoName: string, ): boolean { const cond = repoCondition(r); - if (cond.excludes.includes(repoName)) return false; - if (cond.includes.includes("~ALL") || cond.includes.includes(repoName)) - return true; - return cond.includes.some((p) => p.includes("*")); + const matches = (pattern: string) => + pattern === "~ALL" || globMatches(pattern, repoName); + return cond.includes.some(matches) && !cond.excludes.some(matches); } export function rulesetsForRepo( @@ -56,6 +55,39 @@ export function withRepoStatusChecks( }; } +/** + * An org ruleset is one resource for every repo it covers, so it cannot carry + * a repo's own check names, and it has no form of acceptAnyOf. Provisioning + * either would leave those repos with no required checks and no error. + * Surface every offender at once, before any resource is created. + */ +export function assertOrgRulesetsSupported( + repos: RepoConfig[], + rulesets: RulesetConfig[], +): void { + const requiringChecks = rulesets.filter( + (r) => r.enforcement !== "disabled" && r.rules.requiredStatusChecks.enabled, + ); + const offenders = [ + ...requiringChecks + .filter((r) => !r.rules.requiredStatusChecks.requiredChecks?.length) + .map((r) => `rulesets.${r.id}.rules.requiredStatusChecks.acceptAnyOf`), + ...repos + .filter( + (repo) => + repo.requiredStatusChecks?.length && + requiringChecks.some((r) => rulesetAppliesToRepo(r, repo.name)), + ) + .map((repo) => `repos.${repo.name}.requiredStatusChecks`), + ]; + if (offenders.length > 0) { + const lines = offenders.map((o) => `- ${o}`).join("\n"); + throw new Error( + `enableRulesets is true, but org rulesets cannot enforce these status checks:\n${lines}`, + ); + } +} + export function mergeRepoBranchProtection( repo: RepoConfig, defaultBranch: string, diff --git a/src/setup/utils.ts b/src/setup/utils.ts index 3927b82..2b4bf4d 100644 --- a/src/setup/utils.ts +++ b/src/setup/utils.ts @@ -1,4 +1,4 @@ -import type { RepoConfig } from "@/types"; +import type { RepoConfig, RulesetConfig } from "@/types"; import type { ValidationIssue } from "./types"; export function normalizeBranchPattern( @@ -9,11 +9,64 @@ export function normalizeBranchPattern( return pattern.replace(/^refs\/heads\//, ""); } -/** Ref names for github:RepositoryRuleset / OrganizationRuleset conditions. */ -export function normalizeRulesetRefName(pattern: string): string { +const escapeRegex = (text: string) => + text.replace(/[.+^${}()|[\]\\*?]/g, String.raw`\$&`); + +const GLOB_WILDCARDS: Record = { + "**/": "(?:.*/)?", + "**": ".*", + "*": "[^/]*", + "?": "[^/]", +}; + +// One piece of a glob at a time: a wildcard, an escaped character, a +// character class, or any other single character. +// A class stops at the next bracket of either kind, so a run of unclosed +// `[` is not rescanned to the end from each one. +const GLOB_TOKEN = /\*\*\/?|[*?]|\\(.)|\[([!^]?)([^\][]+)\]|./gs; + +// An fnmatch glob as a regex, the way GitHub reads ruleset patterns: `*` and +// `?` stay within a path segment, `**/` spans any number of segments +// including none, and `[...]` is a character class. +function globToRegexSource(glob: string): string { + return glob.replace( + GLOB_TOKEN, + (token: string, escaped?: string, negation?: string, members?: string) => { + if (escaped !== undefined) return escapeRegex(escaped); + if (members !== undefined) { + const body = members.replace(/[\\\]^]/g, String.raw`\$&`); + return `[${negation ? "^" : ""}${body}]`; + } + return GLOB_WILDCARDS[token] ?? escapeRegex(token); + }, + ); +} + +/** Whether an fnmatch glob, as GitHub reads ruleset conditions, matches a name. */ +export function globMatches(glob: string, name: string): boolean { + return new RegExp(`^${globToRegexSource(glob)}$`).test(name); +} + +export function branchPatternCovers( + pattern: string, + branch: string, + defaultBranch: string, +): boolean { + if (pattern === "~ALL") return true; + return globMatches(normalizeBranchPattern(pattern, defaultBranch), branch); +} + +/** + * Ref names for github:RepositoryRuleset conditions. A bare pattern names a + * tag in a tag ruleset and a branch in any other. + */ +export function normalizeRulesetRefName( + pattern: string, + target: RulesetConfig["target"] = "branch", +): string { if (pattern === "~DEFAULT_BRANCH" || pattern === "~ALL") return pattern; if (pattern.startsWith("refs/")) return pattern; - return `refs/heads/${pattern}`; + return `${target === "tag" ? "refs/tags" : "refs/heads"}/${pattern}`; } export function normalizeActors(actors: string[], org: string): string[] { diff --git a/src/setup/validate.ts b/src/setup/validate.ts index e828b7b..b011a0f 100644 --- a/src/setup/validate.ts +++ b/src/setup/validate.ts @@ -8,7 +8,7 @@ import type { } from "@/types"; import { rulesetAppliesToRepo } from "./rulesets"; import type { ValidationIssue } from "./types"; -import { issue, normalizeBranchPattern } from "./utils"; +import { branchPatternCovers, issue, normalizeBranchPattern } from "./utils"; const compareStrings = (a: string, b: string) => a.localeCompare(b); @@ -72,6 +72,19 @@ function validateTeamRefs(config: InfraConfig): ValidationIssue[] { ), ), + // Each pair becomes one resource named after the repo and the team, so + // a repeat collides in Pulumi, whatever permission it asks for. + ...Object.entries(teams.repoAccess).flatMap(([repoName, access]) => + Object.entries(groupBy(access, (e) => e.team)) + .filter(([, entries]) => entries.length > 1) + .map(([team]) => + issue( + `teams.repoAccess.${repoName}`, + `team "${team}" is listed more than once`, + ), + ), + ), + ...repos.flatMap((repo) => (repo.environments ?? []).flatMap((env) => (env.requiredReviewerTeamSlugs ?? []) @@ -272,6 +285,37 @@ function validateLabelGroups(labelGroups: LabelGroups): ValidationIssue[] { ); } +// Pulumi commits the file straight to the default branch. A ruleset that +// requires pull requests rejects that commit and fails the whole apply. +function validateDependabot(config: InfraConfig): ValidationIssue[] { + const defaultBranch = config.org.defaults.defaultBranch; + const matchesDefaultBranch = (ref: string) => + branchPatternCovers(ref, defaultBranch, defaultBranch); + const coversDefaultBranch = (r: RulesetConfig) => + r.conditions.refName.includes.some(matchesDefaultBranch) && + !(r.conditions.refName.excludes ?? []).some(matchesDefaultBranch); + const requiringPullRequests = config.rulesets.filter( + (r) => + r.enforcement === "active" && + r.target === "branch" && + r.rules.pullRequest && + coversDefaultBranch(r), + ); + + return config.repos + .filter( + (repo) => + repo.dependabot?.length && + requiringPullRequests.some((r) => rulesetAppliesToRepo(r, repo.name)), + ) + .map((repo) => + issue( + `repos.${repo.name}.dependabot`, + "a ruleset on this repo requires pull requests, so the file cannot be synced; add it in the repo", + ), + ); +} + export function validateCrossRefs( config: InfraConfig, labelGroups: LabelGroups, @@ -283,6 +327,7 @@ export function validateCrossRefs( ...validateRepoStatusChecks(config), ...validateRulesetBranchProtectionOverlap(config), ...validateSecurity(config), + ...validateDependabot(config), ...validateLabelGroups(labelGroups), ]; } diff --git a/src/types/config.ts b/src/types/config.ts index 84c7111..5141338 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -2,6 +2,7 @@ import { groupBy } from "es-toolkit"; import * as v from "valibot"; import { LabelSetSchema } from "./label"; import { + DependabotDefaultsSchema, MergeStrategySchema, RepoConfigSchema, RepoVisibilitySchema, @@ -42,6 +43,12 @@ const OrgDefaultsSchema = v.strictObject({ secretScanning: true, pushProtection: true, }), + dependabot: v.optional(DependabotDefaultsSchema, { + interval: "weekly", + openPullRequestsLimit: 5, + labels: [], + assignees: [], + }), }); export const OrgConfigSchema = v.strictObject({ diff --git a/src/types/repo.ts b/src/types/repo.ts index c8291e0..fedea0a 100644 --- a/src/types/repo.ts +++ b/src/types/repo.ts @@ -34,6 +34,41 @@ export const SecuritySchema = v.strictObject({ export type SecurityConfig = v.InferOutput; +export const DependabotEcosystemSchema = v.picklist([ + "bun", + "npm", + "github-actions", + "swift", + "bundler", + "pip", + "docker", +]); + +// How every synced .github/dependabot.yml schedules, labels and assigns its +// pull requests. A repo only chooses its ecosystems. +export const DependabotDefaultsSchema = v.strictObject({ + interval: v.picklist(["daily", "weekly", "monthly"]), + day: v.optional( + v.picklist([ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday", + ]), + ), + openPullRequestsLimit: v.pipe(v.number(), v.integer(), v.minValue(0)), + labels: v.array(v.string()), + assignees: v.array(v.string()), +}); + +export type DependabotDefaults = v.InferOutput; +export type DependabotEcosystem = v.InferOutput< + typeof DependabotEcosystemSchema +>; + // Branch protection mirrors BranchProtectionArgs from Pulumi, so it needs no custom schema. // We only validate the fields users can set in YAML; Pulumi owns the rest. const BranchProtectionSchema = v.strictObject({ @@ -85,6 +120,9 @@ export const RepoConfigSchema = v.pipe( // CODEOWNERS content for this repo, in place of the org template. A // bare repo gets no CODEOWNERS unless it sets this. codeowners: v.optional(v.string()), + // Ecosystems for a synced .github/dependabot.yml. A repo that sets none + // keeps whatever file it has. + dependabot: v.optional(v.array(DependabotEcosystemSchema)), // The checks this repo's CI reports. A ruleset on the repo that requires // status checks requires these, since job names differ per repo. requiredStatusChecks: v.optional(v.array(v.string())), @@ -143,6 +181,7 @@ export interface ResolvedRepoConfig squashMergeCommitMessage: SquashMergeCommitMessage; defaultBranch: string; codeownersContent: string; + dependabotContent: string; pulumiName: string; organization: string; } diff --git a/test/dependabot.test.ts b/test/dependabot.test.ts new file mode 100644 index 0000000..e1b25e1 --- /dev/null +++ b/test/dependabot.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "bun:test"; +import { renderDependabot } from "@/setup"; + +const defaults = { + interval: "weekly", + day: "monday", + openPullRequestsLimit: 10, + labels: ["dependencies"], + assignees: ["someone"], +} as const; + +type Rendered = { + version: number; + updates: { labels?: string[]; assignees?: string[] }[]; +}; + +describe("renderDependabot", () => { + it("writes nothing for a repo with no ecosystems", () => { + expect( + renderDependabot([], { ...defaults, labels: [], assignees: [] }), + ).toBe(""); + }); + + it("keeps labels that hold quotes, backslashes or YAML syntax", () => { + const labels = [ + 'dependency "updates"', + "back\\slash", + "a: b # c", + "'single'", + ]; + const rendered = renderDependabot(["bun", "github-actions"], { + ...defaults, + labels, + assignees: ["someone"], + }); + const parsed = Bun.YAML.parse(rendered) as Rendered; + + expect(parsed.version).toBe(2); + expect(parsed.updates).toHaveLength(2); + for (const update of parsed.updates) { + expect(update.labels).toEqual(labels); + expect(update.assignees).toEqual(["someone"]); + } + }); +}); diff --git a/test/resolve.test.ts b/test/resolve.test.ts index 6e943cd..a6ecd58 100644 --- a/test/resolve.test.ts +++ b/test/resolve.test.ts @@ -21,6 +21,13 @@ const defaults = { secretScanning: true, pushProtection: true, }, + dependabot: { + interval: "weekly", + day: "monday", + openPullRequestsLimit: 10, + labels: ["dependencies"], + assignees: ["someone"], + }, } satisfies OrgConfig["defaults"]; const baseCtx = { @@ -165,6 +172,77 @@ describe("buildRepoConfig", () => { expect(Object.keys(built.labels ?? {})).toEqual(["global", "local"]); }); + it("renders a dependabot.yml for the ecosystems a repo lists", () => { + expect(buildRepoConfig(repo(), baseCtx).dependabotContent).toBe(""); + + const built = buildRepoConfig( + repo({ dependabot: ["bun", "github-actions"] }), + baseCtx, + ); + expect(built.dependabotContent).toBe( + [ + "# Synced from thaw-app/platform. Edit config/repos.yaml there.", + "version: 2", + "updates:", + ' - package-ecosystem: "bun"', + ' directory: "/"', + " schedule:", + ' interval: "weekly"', + ' day: "monday"', + " open-pull-requests-limit: 10", + " labels:", + ' - "dependencies"', + " assignees:", + ' - "someone"', + " groups:", + " bun:", + " patterns:", + ' - "*"', + " update-types:", + ' - "minor"', + ' - "patch"', + "", + ' - package-ecosystem: "github-actions"', + ' directory: "/"', + " schedule:", + ' interval: "weekly"', + ' day: "monday"', + " open-pull-requests-limit: 10", + " labels:", + ' - "dependencies"', + " assignees:", + ' - "someone"', + " groups:", + " github-actions:", + " patterns:", + ' - "*"', + " update-types:", + ' - "minor"', + ' - "patch"', + "", + ].join("\n"), + ); + }); + + it("leaves out the day, labels and assignees the defaults do not set", () => { + const built = buildRepoConfig(repo({ dependabot: ["npm"] }), { + ...baseCtx, + defaults: { + ...defaults, + dependabot: { + interval: "monthly", + openPullRequestsLimit: 5, + labels: [], + assignees: [], + }, + }, + }); + expect(built.dependabotContent).not.toContain("day:"); + expect(built.dependabotContent).not.toContain("labels:"); + expect(built.dependabotContent).not.toContain("assignees:"); + expect(built.dependabotContent).toContain('interval: "monthly"'); + }); + it("bare repos skip org labels and CODEOWNERS, and keep their rulesets", () => { const built = buildRepoConfig(repo({ name: "brand-assets", bare: true }), { ...baseCtx, diff --git a/test/resources.test.ts b/test/resources.test.ts index 9c19c62..402fb30 100644 --- a/test/resources.test.ts +++ b/test/resources.test.ts @@ -79,6 +79,7 @@ const resolvedRepo = ( squashMergeCommitMessage: "COMMIT_MESSAGES", defaultBranch: "main", codeownersContent: "* @acme-org/maintainers\n", + dependabotContent: "", ...overrides, }); @@ -282,6 +283,22 @@ describe("OrgRepository", () => { expect("autocreateBranch" in codeowners.inputs).toBe(false); }); + it("syncs dependabot.yml next to CODEOWNERS when the repo has one", async () => { + provisionOrgRepository( + "r", + resolvedRepo({ dependabotContent: "version: 2\nupdates: []\n" }), + {}, + ); + await settle(); + + const files = findByType("github:index/repositoryFile:RepositoryFile"); + expect(files.map((f) => f.inputs.file)).toEqual([ + ".github/CODEOWNERS", + ".github/dependabot.yml", + ]); + expect(files.map((f) => f.name)).toEqual(["r-codeowners", "r-dependabot"]); + }); + it("adopts an existing GitHub repository when adopt is true", async () => { provisionOrgRepository( "r", @@ -397,6 +414,26 @@ it("asks for no pull request when the ruleset has no pull request rule", async ( expect(sent.nonFastForward).toBe(true); }); +it("sends a tag ruleset's bare patterns as tag refs", async () => { + const repo = new github.Repository("r", { name: "r" }); + await settle(); + createRepositoryRulesets("website", repo, [ + mainRuleset({ + id: "tags", + target: "tag", + conditions: { refName: { includes: ["v*"], excludes: ["v0*"] } }, + }), + ]); + await settle(); + const rs = first( + findByType("github:index/repositoryRuleset:RepositoryRuleset"), + ); + expect((rs.inputs.conditions as { refName: unknown }).refName).toEqual({ + includes: ["refs/tags/v*"], + excludes: ["refs/tags/v0*"], + }); +}); + it("passes bypassActors through to repository rulesets", async () => { const repo = new github.Repository("r", { name: "r" }); await settle(); diff --git a/test/rulesets.test.ts b/test/rulesets.test.ts index cabdb0a..a2357a2 100644 --- a/test/rulesets.test.ts +++ b/test/rulesets.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test"; import { + assertOrgRulesetsSupported, mergeRepoBranchProtection, rulesetAppliesToRepo, rulesetsForRepo, @@ -59,6 +60,76 @@ describe("rulesetAppliesToRepo", () => { expect(rulesetAppliesToRepo(ruleset, "website")).toBe(false); expect(rulesetAppliesToRepo(ruleset, "org-ci")).toBe(true); }); + + it("matches a wildcard include against the repo's name", () => { + const ruleset = mainRuleset({ + conditions: { + refName: { includes: ["~DEFAULT_BRANCH"] }, + repositoryName: { includes: ["release-*"], excludes: [] }, + }, + }); + expect(rulesetAppliesToRepo(ruleset, "release-notes")).toBe(true); + expect(rulesetAppliesToRepo(ruleset, "website")).toBe(false); + }); + + it("matches a wildcard exclude against the repo's name", () => { + const ruleset = mainRuleset({ + conditions: { + refName: { includes: ["~DEFAULT_BRANCH"] }, + repositoryName: { includes: ["~ALL"], excludes: ["web*"] }, + }, + }); + expect(rulesetAppliesToRepo(ruleset, "website")).toBe(false); + expect(rulesetAppliesToRepo(ruleset, "org-ci")).toBe(true); + }); +}); + +describe("assertOrgRulesetsSupported", () => { + const repo = (overrides: Partial = {}): RepoConfig => ({ + name: "website", + description: "d", + ...overrides, + }); + const namedChecks = () => + mainRuleset({ + rules: { + ...defaultRules(), + requiredStatusChecks: { + enabled: true, + requiredChecks: [{ context: "ci" }], + }, + }, + }); + + it("accepts rulesets that name their required checks", () => { + expect(() => + assertOrgRulesetsSupported([repo()], [namedChecks()]), + ).not.toThrow(); + }); + + it("refuses acceptAnyOf, which an org ruleset would drop", () => { + expect(() => assertOrgRulesetsSupported([repo()], [mainRuleset()])).toThrow( + /rulesets\.main-default\.rules\.requiredStatusChecks\.acceptAnyOf/, + ); + }); + + it("refuses a repo's own check names, which an org ruleset cannot carry", () => { + expect(() => + assertOrgRulesetsSupported( + [repo({ requiredStatusChecks: ["check"] })], + [namedChecks()], + ), + ).toThrow(/repos\.website\.requiredStatusChecks/); + }); + + it("ignores disabled rulesets", () => { + expect(() => + assertOrgRulesetsSupported( + [repo()], + [mainRuleset({ enforcement: "disabled" })], + ), + ).not.toThrow(); + }); }); describe("rulesetsForRepo", () => { diff --git a/test/schema.test.ts b/test/schema.test.ts index 86805db..34f78b6 100644 --- a/test/schema.test.ts +++ b/test/schema.test.ts @@ -44,6 +44,38 @@ describe("LabelDefinitionSchema", () => { }); describe("OrgConfigSchema", () => { + it("rejects a Dependabot day or limit that Dependabot would refuse", () => { + const withDependabot = (dependabot: Record) => + v.safeParse(OrgConfigSchema, { + owner: "acme", + organization: "acme-org", + defaults: { + visibility: "public", + defaultBranch: "main", + deleteBranchOnMerge: true, + mergeStrategies: ["squash"], + features: { + issues: true, + wiki: false, + projects: false, + discussions: false, + }, + dependabot: { + interval: "weekly", + openPullRequestsLimit: 10, + labels: [], + assignees: [], + ...dependabot, + }, + }, + }).success; + + expect(withDependabot({ day: "monday" })).toBe(true); + expect(withDependabot({ day: "someday" })).toBe(false); + expect(withDependabot({ openPullRequestsLimit: 2.5 })).toBe(false); + expect(withDependabot({ openPullRequestsLimit: -1 })).toBe(false); + }); + it("applies squash-merge and security defaults when omitted", () => { const r = v.safeParse(OrgConfigSchema, { owner: "acme", diff --git a/test/utils.test.ts b/test/utils.test.ts index 7de142c..4aaad36 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -1,12 +1,60 @@ import { describe, expect, it } from "bun:test"; import { assertReviewerTeamsCreatable, + branchPatternCovers, normalizeActors, normalizeBranchPattern, normalizeRulesetRefName, } from "@/setup"; import type { RepoConfig } from "@/types"; +describe("branchPatternCovers", () => { + const covers = (pattern: string, branch = "main") => + branchPatternCovers(pattern, branch, "main"); + + it("matches the names GitHub's special tokens stand for", () => { + expect(covers("~ALL")).toBe(true); + expect(covers("~DEFAULT_BRANCH")).toBe(true); + expect(covers("~DEFAULT_BRANCH", "release")).toBe(false); + expect(covers("refs/heads/main")).toBe(true); + }); + + it("keeps * and ? within one path segment", () => { + expect(covers("ma*")).toBe(true); + expect(covers("*")).toBe(true); + expect(covers("mai?")).toBe(true); + expect(covers("*", "release/v1")).toBe(false); + expect(covers("release/*", "release/v1")).toBe(true); + expect(covers("release/*")).toBe(false); + }); + + it("lets ** span segments, including none", () => { + expect(covers("**/*")).toBe(true); + expect(covers("**")).toBe(true); + expect(covers("**/main")).toBe(true); + expect(covers("**/*", "release/v1")).toBe(true); + expect(covers("release/**", "release/v1/hotfix")).toBe(true); + expect(covers("release/**")).toBe(false); + }); + + it("reads character classes", () => { + expect(covers("m[ai]in")).toBe(true); + expect(covers("m[!a]in")).toBe(false); + expect(covers("[a-m]ain")).toBe(true); + expect(covers("m[xyz]in")).toBe(false); + }); + + it("takes other punctuation literally", () => { + expect(covers("ma.n")).toBe(false); + expect(covers("v1.0", "v1.0")).toBe(true); + expect(covers("v1.0", "v1x0")).toBe(false); + expect(covers("a+b", "a+b")).toBe(true); + expect(covers(String.raw`ma\*n`, "ma*n")).toBe(true); + expect(covers(String.raw`ma\*n`)).toBe(false); + expect(covers("ma[in", "ma[in")).toBe(true); + }); +}); + describe("normalizeBranchPattern", () => { it("expands ~DEFAULT_BRANCH to the default branch", () => { expect(normalizeBranchPattern("~DEFAULT_BRANCH", "main")).toBe("main"); @@ -39,6 +87,13 @@ describe("normalizeRulesetRefName", () => { expect(normalizeRulesetRefName("main")).toBe("refs/heads/main"); expect(normalizeRulesetRefName("release/*")).toBe("refs/heads/release/*"); }); + + it("prefixes bare patterns as tags in a tag ruleset", () => { + expect(normalizeRulesetRefName("v*", "tag")).toBe("refs/tags/v*"); + expect(normalizeRulesetRefName("refs/tags/v1", "tag")).toBe("refs/tags/v1"); + expect(normalizeRulesetRefName("~ALL", "tag")).toBe("~ALL"); + expect(normalizeRulesetRefName("main", "push")).toBe("refs/heads/main"); + }); }); describe("normalizeActors", () => { diff --git a/test/validate.test.ts b/test/validate.test.ts index 6cf7e0c..6e1dc13 100644 --- a/test/validate.test.ts +++ b/test/validate.test.ts @@ -21,6 +21,13 @@ const defaults = { secretScanning: true, pushProtection: true, }, + dependabot: { + interval: "weekly", + day: "monday", + openPullRequestsLimit: 10, + labels: ["dependencies"], + assignees: ["someone"], + }, } satisfies OrgConfig["defaults"]; const baseConfig = (): InfraConfig => ({ @@ -87,6 +94,19 @@ describe("validateCrossRefs", () => { expect(issues[0]?.message).toMatch(/unknown team "ghosts"/); }); + it("flags a team listed twice for one repo, whatever the permissions", () => { + const cfg = baseConfig(); + cfg.teams.repoAccess = { + r: [ + { team: "maintainers", permission: "admin" }, + { team: "maintainers", permission: "push" }, + ], + }; + expect(validateCrossRefs(cfg, {}).map((i) => [i.path, i.message])).toEqual([ + ["teams.repoAccess.r", 'team "maintainers" is listed more than once'], + ]); + }); + it("flags environment reviewers referencing an unknown team", () => { const cfg = baseConfig(); repoEntry(cfg).environments = [ @@ -305,4 +325,73 @@ describe("validateCrossRefs", () => { ["repos.s.security", "pushProtection needs secretScanning", "error"], ]); }); + + it("rejects a synced dependabot.yml on a repo whose ruleset requires pull requests", () => { + const cfg = baseConfig(); + cfg.repos = [ + { name: "reviewed", description: "d", dependabot: ["bun"] }, + { name: "open", description: "d", dependabot: ["bun"] }, + { name: "plain", description: "d" }, + { name: "release-only", description: "d", dependabot: ["bun"] }, + { name: "wildcard", description: "d", dependabot: ["bun"] }, + { name: "excluded", description: "d", dependabot: ["bun"] }, + { name: "every-depth", description: "d", dependabot: ["bun"] }, + ]; + const basic = mainRuleset("basic"); + const { pullRequest: _none, ...basicRules } = basic.rules; + cfg.rulesets = [ + { + ...mainRuleset("reviewed"), + conditions: { + refName: { includes: ["~DEFAULT_BRANCH"] }, + repositoryName: { includes: ["reviewed", "plain"], excludes: [] }, + }, + }, + { + ...basic, + rules: basicRules, + conditions: { + refName: { includes: ["~DEFAULT_BRANCH"] }, + repositoryName: { includes: ["open"], excludes: [] }, + }, + }, + // Requires pull requests, but not on the branch the file is written to. + { + ...mainRuleset("releases"), + conditions: { + refName: { includes: ["release/*"] }, + repositoryName: { includes: ["release-only"], excludes: [] }, + }, + }, + // A pattern that covers the default branch without naming it. + { + ...mainRuleset("wildcard"), + conditions: { + refName: { includes: ["ma*"] }, + repositoryName: { includes: ["wildcard"], excludes: [] }, + }, + }, + // Covers every branch but the default one. + { + ...mainRuleset("excluded"), + conditions: { + refName: { includes: ["~ALL"], excludes: ["~DEFAULT_BRANCH"] }, + repositoryName: { includes: ["excluded"], excludes: [] }, + }, + }, + // GitHub reads this as every branch, at any depth. + { + ...mainRuleset("every-depth"), + conditions: { + refName: { includes: ["**/*"] }, + repositoryName: { includes: ["every-depth"], excludes: [] }, + }, + }, + ]; + expect(validateCrossRefs(cfg, {}).map((i) => i.path)).toEqual([ + "repos.reviewed.dependabot", + "repos.wildcard.dependabot", + "repos.every-depth.dependabot", + ]); + }); });