diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a215ba..9e4b0ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,16 @@ env: jobs: test: + if: | + github.event_name == 'pull_request' || + github.event_name == 'merge_group' || + github.event_name == 'workflow_dispatch' || + ( + github.event_name == 'push' && + github.ref == 'refs/heads/main' && + !startsWith(github.event.head_commit.message, 'Merge pull request') && + !contains(github.event.head_commit.message, '(#') + ) runs-on: ubuntu-slim steps: - uses: actions/checkout@v6 @@ -67,8 +77,13 @@ jobs: comment-on-pr: true deploy: - if: github.event_name == 'push' && github.ref == 'refs/heads/main' needs: test + if: | + github.event_name == 'push' && + github.ref == 'refs/heads/main' && + always() && + !cancelled() && + (needs.test.result == 'success' || needs.test.result == 'skipped') permissions: id-token: write pull-requests: write diff --git a/README.md b/README.md index ceb1eb4..aa638b6 100644 --- a/README.md +++ b/README.md @@ -109,12 +109,11 @@ flowchart LR end pr --> test - push --> test dispatch --> test test --> preview + push --> test test --> deploy pr -.-> preview - push -.-> deploy ``` | Workflow | File | When it runs | @@ -123,7 +122,18 @@ flowchart LR | **Pulumi Setup** | [`.github/workflows/pulumi.yml`](.github/workflows/pulumi.yml) | Reusable: `preview` on PRs, `up` on `main`, or `preview --expect-no-changes` for drift | | **Drift detection** | [`.github/workflows/drift.yml`](.github/workflows/drift.yml) | Weekly (Mondays 06:17 UTC) or `workflow_dispatch` | -On `main`, the `deploy` job runs `pulumi up` against stack `diazdesandi/dev` after `test` passes. CI authenticates to Pulumi Cloud via OIDC; the GitHub provider uses the `PULUMI_GITHUB_TOKEN` repository secret (Actions' default `GITHUB_TOKEN` cannot manage org teams, labels, or cross-repo resources). Only one of `preview` or `deploy` runs per workflow invocation — PRs preview, pushes to `main` deploy. +| Trigger | Jobs | +| --- | --- | +| **PR opened / updated** | test → preview | +| **PR merged → push to `main`** | deploy only (merge commit detected; tests already ran on the PR) | +| **Direct push to `main`** | test → deploy | +| **`workflow_dispatch`** | test (manual) | + +On push to `main`, merge commits are detected by message (`Merge pull request …` or squash `… (#123)`). Those skip **test** and run **deploy** only. A direct push (no merge markers) runs **test** then **deploy**. + +To skip CI on a PR commit, add `[skip ci]` to the commit message. Required status checks will not run for that commit. + +On `main`, **deploy** runs `pulumi up` against stack `diazdesandi/dev`. CI authenticates to Pulumi Cloud via OIDC; the GitHub provider uses the `PULUMI_GITHUB_TOKEN` repository secret (Actions' default `GITHUB_TOKEN` cannot manage org teams, labels, or cross-repo resources). ### Secrets @@ -183,7 +193,7 @@ Set `autoInit: true` when Pulumi should create an empty GitHub repo (GitHub seed **Add a team** — add it under `teams:` in `config/teams.yaml`, then reference its `slug` in `repoAccess` and/or `config/members.yaml`. -**Add a member** — append an entry to `config/members.yaml` with their `username` and team `slug`/`role` pairs. +**Add a member** — append an entry to `config/members.yaml` with their `username` and team `slug`s. Omit `role` for the default (`member`); set `role: maintainer` only when they should manage that team's roster. **Add a ruleset** — append to `config/rulesets.yaml`. A branch pattern may be owned by only one ruleset (validation enforces this). diff --git a/config/members.yaml b/config/members.yaml index 1427a8d..866cab5 100644 --- a/config/members.yaml +++ b/config/members.yaml @@ -5,16 +5,12 @@ members: - slug: maintainers role: maintainer - slug: contributors - role: member - username: stonerl teams: - slug: maintainers role: maintainer - slug: contributors - role: member - username: nightah teams: - slug: contributors - role: member - slug: maintainers - role: member diff --git a/config/schema/members.schema.json b/config/schema/members.schema.json index 7c083b1..96b0354 100644 --- a/config/schema/members.schema.json +++ b/config/schema/members.schema.json @@ -19,10 +19,11 @@ }, "role": { "enum": ["member", "maintainer"], - "type": "string" + "type": "string", + "default": "member" } }, - "required": ["slug", "role"], + "required": ["slug"], "additionalProperties": false } } diff --git a/src/setup/members.ts b/src/setup/members.ts index d4d2e12..03ef92b 100644 --- a/src/setup/members.ts +++ b/src/setup/members.ts @@ -1,5 +1,5 @@ import type { - MembersFile, + MembersFileInput, TeamConfig, TeamMemberConfig, TeamsConfig, @@ -9,7 +9,7 @@ import type { /** Fold members.yaml into the team-centric shape the resource layer expects. */ export function mergeTeamMemberships( teamsFile: TeamsFile, - membersFile: MembersFile, + membersFile: MembersFileInput, ): TeamsConfig { const membersByTeam = new Map( teamsFile.teams.map((team) => [team.slug, []]), @@ -19,7 +19,7 @@ export function mergeTeamMemberships( for (const { slug, role } of teams) { const bucket = membersByTeam.get(slug); if (!bucket) continue; // cross-ref validation reports unknown slugs - bucket.push({ username, role }); + bucket.push({ username, role: role ?? "member" }); } } diff --git a/src/setup/validate.ts b/src/setup/validate.ts index 3a0787c..5aee7f2 100644 --- a/src/setup/validate.ts +++ b/src/setup/validate.ts @@ -2,7 +2,7 @@ import { groupBy, uniq } from "es-toolkit"; import type { InfraConfig, LabelGroups, - MembersFile, + MembersFileInput, RulesetConfig, TeamsFile, } from "@/types"; @@ -89,7 +89,7 @@ function validateTeamRefs(config: InfraConfig): ValidationIssue[] { export function validateMemberRefs( teamsFile: TeamsFile, - membersFile: MembersFile, + membersFile: MembersFileInput, ): ValidationIssue[] { const teamSlugs = new Set(teamsFile.teams.map((t) => t.slug)); diff --git a/src/types/member.ts b/src/types/member.ts index 22f3389..41eed87 100644 --- a/src/types/member.ts +++ b/src/types/member.ts @@ -4,7 +4,7 @@ import { TeamMemberRoleSchema } from "./team"; const MemberTeamEntrySchema = v.strictObject({ slug: v.string(), - role: TeamMemberRoleSchema, + role: v.optional(TeamMemberRoleSchema, "member"), }); export const MemberEntrySchema = v.strictObject({ @@ -53,5 +53,7 @@ export const MembersFileSchema = v.pipe( }), ); +export type MemberEntryInput = v.InferInput; export type MemberEntry = v.InferOutput; +export type MembersFileInput = v.InferInput; export type MembersFile = v.InferOutput; diff --git a/test/members.test.ts b/test/members.test.ts index 68d50a7..b5898d6 100644 --- a/test/members.test.ts +++ b/test/members.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "bun:test"; import { mergeTeamMemberships } from "@/setup/members"; import { validateMemberRefs } from "@/setup/validate"; -import type { MembersFile, TeamsFile } from "@/types"; +import type { MembersFileInput, TeamsFile } from "@/types"; const teamsFile = (): TeamsFile => ({ teams: [ @@ -13,16 +13,16 @@ const teamsFile = (): TeamsFile => ({ describe("mergeTeamMemberships", () => { it("folds member-centric YAML into team members arrays", () => { - const members: MembersFile = { + const members: MembersFileInput = { members: [ { username: "alice", teams: [ { slug: "maintainers", role: "maintainer" }, - { slug: "contributors", role: "member" }, + { slug: "contributors" }, ], }, - { username: "bob", teams: [{ slug: "contributors", role: "member" }] }, + { username: "bob", teams: [{ slug: "contributors" }] }, ], }; @@ -36,6 +36,15 @@ describe("mergeTeamMemberships", () => { ]); }); + it("defaults omitted role to member", () => { + const merged = mergeTeamMemberships(teamsFile(), { + members: [{ username: "alice", teams: [{ slug: "contributors" }] }], + }); + expect(merged.teams[1]?.members).toEqual([ + { username: "alice", role: "member" }, + ]); + }); + it("omits members key when a team has no members", () => { const merged = mergeTeamMemberships(teamsFile(), { members: [] }); expect(merged.teams[0]).not.toHaveProperty("members"); @@ -45,9 +54,7 @@ describe("mergeTeamMemberships", () => { describe("validateMemberRefs", () => { it("flags unknown team slugs in members.yaml", () => { const issues = validateMemberRefs(teamsFile(), { - members: [ - { username: "alice", teams: [{ slug: "unknown", role: "member" }] }, - ], + members: [{ username: "alice", teams: [{ slug: "unknown" }] }], }); expect(issues).toHaveLength(1); expect(issues[0]?.path).toBe("members.0.teams.0.slug"); diff --git a/test/schema.test.ts b/test/schema.test.ts index a20b295..a4db260 100644 --- a/test/schema.test.ts +++ b/test/schema.test.ts @@ -139,7 +139,7 @@ describe("duplicate detection", () => { it("rejects duplicate usernames in members.yaml", () => { const r = v.safeParse(MembersFileSchema, { members: [ - { username: "alice", teams: [{ slug: "eng", role: "member" }] }, + { username: "alice", teams: [{ slug: "eng" }] }, { username: "alice", teams: [{ slug: "eng", role: "maintainer" }] }, ], }); @@ -151,6 +151,16 @@ describe("duplicate detection", () => { } }); + it("defaults omitted team role to member", () => { + const r = v.safeParse(MembersFileSchema, { + members: [{ username: "alice", teams: [{ slug: "eng" }] }], + }); + expect(r.success).toBe(true); + if (r.success) { + expect(r.output.members[0]?.teams[0]?.role).toBe("member"); + } + }); + it("rejects duplicate environment names within a repo", () => { const r = v.safeParse(RepoConfigSchema, { name: "r",