Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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

Expand Down Expand Up @@ -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).

Expand Down
4 changes: 0 additions & 4 deletions config/members.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 3 additions & 2 deletions config/schema/members.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@
},
"role": {
"enum": ["member", "maintainer"],
"type": "string"
"type": "string",
"default": "member"
}
},
"required": ["slug", "role"],
"required": ["slug"],
"additionalProperties": false
}
}
Expand Down
6 changes: 3 additions & 3 deletions src/setup/members.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type {
MembersFile,
MembersFileInput,
TeamConfig,
TeamMemberConfig,
TeamsConfig,
Expand All @@ -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<string, TeamMemberConfig[]>(
teamsFile.teams.map((team) => [team.slug, []]),
Expand All @@ -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" });
}
}

Expand Down
4 changes: 2 additions & 2 deletions src/setup/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { groupBy, uniq } from "es-toolkit";
import type {
InfraConfig,
LabelGroups,
MembersFile,
MembersFileInput,
RulesetConfig,
TeamsFile,
} from "@/types";
Expand Down Expand Up @@ -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));

Expand Down
4 changes: 3 additions & 1 deletion src/types/member.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -53,5 +53,7 @@ export const MembersFileSchema = v.pipe(
}),
);

export type MemberEntryInput = v.InferInput<typeof MemberEntrySchema>;
export type MemberEntry = v.InferOutput<typeof MemberEntrySchema>;
export type MembersFileInput = v.InferInput<typeof MembersFileSchema>;
export type MembersFile = v.InferOutput<typeof MembersFileSchema>;
21 changes: 14 additions & 7 deletions test/members.test.ts
Original file line number Diff line number Diff line change
@@ -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: [
Expand All @@ -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" }] },
],
};

Expand All @@ -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");
Expand All @@ -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");
Expand Down
12 changes: 11 additions & 1 deletion test/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }] },
],
});
Expand All @@ -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",
Expand Down