diff --git a/.github/rulesets/gates.json b/.github/rulesets/gates.json new file mode 100644 index 0000000..c5c7849 --- /dev/null +++ b/.github/rulesets/gates.json @@ -0,0 +1,42 @@ +{ + "name": "gates", + "target": "branch", + "enforcement": "active", + "bypass_actors": [], + "conditions": { + "ref_name": { + "include": ["refs/heads/main"], + "exclude": [] + } + }, + "rules": [ + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": false, + "require_code_owner_review": false, + "require_last_push_approval": false, + "required_review_thread_resolution": false + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": true, + "required_status_checks": [ + { "context": "check (format)" }, + { "context": "check (lint)" }, + { "context": "check (typecheck)" }, + { "context": "check (test)" }, + { "context": "check (dist)" }, + { "context": "check (sfs-sources)" }, + { "context": "journeys" }, + { "context": "lint PR commits" }, + { "context": "changeset · shared tooling / a publishable-package change needs an intent" }, + { "context": "rules" } + ] + } + } + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e0efb2..9c88ffd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,3 +79,19 @@ jobs: apps/site-e2e/artifacts/ .journeys/dev.log if-no-files-found: ignore + + rules: + name: rules + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: ./.github/actions/dev-shell + - name: Main's active rules require every pull-request check + env: + GITHUB_TOKEN: ${{ github.token }} + run: nix develop --command pnpm check:branch-rules diff --git a/README.md b/README.md index 669b22f..c70243e 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,17 @@ To remove it: Removal leaves a deployed D1 as it is: the `guestbook_entries` table and its `0001_create_guestbook_entries.sql` row in `__alchemy_migrations` stay; drop them from the D1 console in the Cloudflare dashboard with `DROP TABLE guestbook_entries; DELETE FROM __alchemy_migrations WHERE name = '0001_create_guestbook_entries.sql';`. -### 5. Deploy +### 5. Make the Gates Block Merges + +A copy gets the template's files, not its repository settings, so nothing stops a pull request with failing checks from merging until `main` has a ruleset that requires them. Install the "gates" ruleset from [`.github/rulesets/gates.json`](.github/rulesets/gates.json) once, with your own GitHub credentials: + +```bash +gh api -X POST repos/{owner}/{repo}/rulesets --input .github/rulesets/gates.json +``` + +Until it is installed, CI's `rules` check fails on every pull request and every push to `main`, and the production deploy waits on it. + +### 6. Deploy Your copy deploys to your own Cloudflare account; the template holds no credentials. Set `CLOUDFLARE_API_TOKEN` (a token that can edit Workers and D1) and `CLOUDFLARE_ACCOUNT_ID`, and optionally `SITE_DOMAIN` (a hostname in a zone on that account) to serve production there instead of on `workers.dev`: diff --git a/package.json b/package.json index 508bd11..eb5c541 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "@systemfsoftware/stryker-js": "catalog:", "@systemfsoftware/tsconfig": "catalog:", "@types/node": "catalog:", + "effect": "catalog:", "vitest": "catalog:", "lint-staged": "catalog:", "oxlint": "catalog:", @@ -26,6 +27,7 @@ "dev": "sandbox --publish 1337:1337 -- pnpm --filter @endgame/site dev", "journeys": "sandbox --listen 1337 --pass-env PLAYWRIGHT_BROWSERS_PATH -- ./bin/journeys", "check:ci": "s=0; pnpm format:check || s=1; pnpm check:sfs-sources || s=1; TURBO_CONCURRENCY=${TURBO_CONCURRENCY:-100%} pnpm gate:tasks || s=1; pnpm gate:dist || s=1; pnpm test:sandbox || s=1; exit $s", + "check:branch-rules": "sandbox --allow-host api.github.com --allow-host jsr.io --pass-env GITHUB_TOKEN --pass-env GITHUB_REPOSITORY -- ./scripts/check-branch-rules.ts", "check:sfs-sources": "sandbox --allow-host jsr.io -- ./scripts/check-sfs-sources.ts", "clean": "sandbox -- turbo clean", "deploy": "./bin/cloud pnpm --filter @endgame/site exec alchemy deploy --stage \"${ALCHEMY_STAGE:-prod}\" --no-input --yes", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 35f5d93..72463ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -322,6 +322,9 @@ importers: '@types/node': specifier: 'catalog:' version: 24.19.1 + effect: + specifier: 'catalog:' + version: 4.0.1 lint-staged: specifier: 'catalog:' version: 17.6.0 diff --git a/scripts/check-branch-rules.test.ts b/scripts/check-branch-rules.test.ts new file mode 100644 index 0000000..6034699 --- /dev/null +++ b/scripts/check-branch-rules.test.ts @@ -0,0 +1,85 @@ +import { assert, assertEquals } from '@std/assert' +import { Result } from 'effect' + +import { branchRulesVerdict, decodeBranchRules, REQUIRED_CHECKS } from './check-branch-rules.ts' + +const R22_CHECKS = [ + 'check (format)', + 'check (lint)', + 'check (typecheck)', + 'check (test)', + 'check (dist)', + 'check (sfs-sources)', + 'journeys', + 'lint PR commits', + 'changeset · shared tooling / a publishable-package change needs an intent', + 'rules', +] + +const RULESET_SOURCE = { + ruleset_source_type: 'Repository', + ruleset_source: 'systemfsoftware/effect-endgame-starter-kit', + ruleset_id: 22783333, +} + +const DELETION = { type: 'deletion', ...RULESET_SOURCE } +const NON_FAST_FORWARD = { type: 'non_fast_forward', ...RULESET_SOURCE } +const PULL_REQUEST = { + type: 'pull_request', + ...RULESET_SOURCE, + parameters: { + required_approving_review_count: 1, + dismiss_stale_reviews_on_push: true, + required_reviewers: [], + require_code_owner_review: false, + dismissal_restriction: { enabled: false, allowed_actors: [] }, + require_last_push_approval: false, + required_review_thread_resolution: false, + require_extra_approval_for_unattributed_changes: false, + allowed_merge_methods: ['merge', 'squash'], + }, +} + +const INTEGRATION_IDS = [{ integration_id: 15368 }, { integration_id: null }, {}] + +const statusChecks = (contexts: readonly string[]) => ({ + type: 'required_status_checks', + ruleset_source_type: 'Repository', + ruleset_source: 'owner/copy', + ruleset_id: 1, + parameters: { + strict_required_status_checks_policy: false, + required_status_checks: contexts.map((context, index) => ({ + context, + ...INTEGRATION_IDS[index % INTEGRATION_IDS.length], + })), + }, +}) + +const verdictOn = (body: unknown, requiredChecks: readonly string[]): readonly string[] => { + const decoded = decodeBranchRules(body) + assert(Result.isSuccess(decoded), `GitHub's documented rule list failed to decode: ${JSON.stringify(body)}`) + return branchRulesVerdict(decoded.success, requiredChecks) +} + +Deno.test('the verdict names exactly the required checks the status-check rules omit, whatever else main enforces', () => { + for (let mask = 0; mask < 2 ** R22_CHECKS.length; mask++) { + const omitted = R22_CHECKS.filter((_, index) => (mask & (1 << index)) !== 0) + const enforced = R22_CHECKS.filter((_, index) => (mask & (1 << index)) === 0) + const half = Math.ceil(enforced.length / 2) + const layouts = [ + [statusChecks(enforced)], + [statusChecks(enforced.slice(0, half)), statusChecks(enforced.slice(half))], + [DELETION, statusChecks(enforced), NON_FAST_FORWARD, PULL_REQUEST, statusChecks(['preview · deploy'])], + ] + for (const rules of layouts) assertEquals(verdictOn(rules, R22_CHECKS), omitted, JSON.stringify(rules)) + } +}) + +Deno.test('a main with no active rules is refused, naming every required check', () => { + assertEquals(verdictOn([], REQUIRED_CHECKS), R22_CHECKS) +}) + +Deno.test("the template's rules today, deletion + non_fast_forward + pull_request, are refused, naming every required check", () => { + assertEquals(verdictOn([DELETION, NON_FAST_FORWARD, PULL_REQUEST], REQUIRED_CHECKS), R22_CHECKS) +}) diff --git a/scripts/check-branch-rules.ts b/scripts/check-branch-rules.ts new file mode 100755 index 0000000..5837135 --- /dev/null +++ b/scripts/check-branch-rules.ts @@ -0,0 +1,87 @@ +#!/usr/bin/env -S deno run --config=scripts/deno.json --allow-net=api.github.com --allow-env=GITHUB_TOKEN,GITHUB_REPOSITORY +import { Result, Schema as S } from 'effect' + +export const REQUIRED_CHECKS = [ + 'check (format)', + 'check (lint)', + 'check (typecheck)', + 'check (test)', + 'check (dist)', + 'check (sfs-sources)', + 'journeys', + 'lint PR commits', + 'changeset · shared tooling / a publishable-package change needs an intent', + 'rules', +] as const + +const StatusChecksRule = S.Struct({ + type: S.Literal('required_status_checks'), + parameters: S.Struct({ + required_status_checks: S.Array(S.Struct({ context: S.String, integration_id: S.optionalKey(S.NullOr(S.Int)) })), + }), +}) +type StatusChecksRule = S.Schema.Type + +const OtherRule = S.Struct({ + type: S.String.pipe(S.check(S.makeFilter((type: string) => type !== 'required_status_checks'))), +}) + +const BranchRules = S.Array(S.Union([StatusChecksRule, OtherRule])) + +export type BranchRule = S.Schema.Type[number] + +export const decodeBranchRules = S.decodeUnknownResult(BranchRules) + +const isStatusChecksRule = (rule: BranchRule): rule is StatusChecksRule => rule.type === 'required_status_checks' + +export const branchRulesVerdict = ( + rules: readonly BranchRule[], + requiredChecks: readonly string[], +): readonly string[] => { + const enforced = new Set( + rules.filter(isStatusChecksRule).flatMap((rule) => + rule.parameters.required_status_checks.map((check) => check.context) + ), + ) + return requiredChecks.filter((name) => !enforced.has(name)) +} + +if (import.meta.main) { + const repository = Deno.env.get('GITHUB_REPOSITORY') + const token = Deno.env.get('GITHUB_TOKEN') + if (repository === undefined || token === undefined) { + console.error('check-branch-rules: set GITHUB_REPOSITORY (owner/repo) and GITHUB_TOKEN') + Deno.exit(1) + } + const path = `/repos/${repository}/rules/branches/main` + const response = await fetch(`https://api.github.com${path}?per_page=100`, { + headers: { + accept: 'application/vnd.github+json', + authorization: `Bearer ${token}`, + 'user-agent': 'check-branch-rules', + 'x-github-api-version': '2022-11-28', + }, + }) + console.log(`check-branch-rules: GET ${path} -> HTTP ${response.status}`) + if (!response.ok) { + console.error(`check-branch-rules: ${await response.text()}`) + Deno.exit(1) + } + const decoded = decodeBranchRules(await response.json()) + if (Result.isFailure(decoded)) { + console.error(`check-branch-rules: the response is not GitHub's documented rule list:\n${decoded.failure.message}`) + Deno.exit(1) + } + const rules = decoded.success + console.log(`check-branch-rules: active rule types: ${rules.map((rule) => rule.type).join(', ') || '(none)'}`) + const missing = branchRulesVerdict(rules, REQUIRED_CHECKS) + if (missing.length > 0) { + console.error( + `check-branch-rules: main's active rules do not require ${missing.length} of the ${REQUIRED_CHECKS.length} checks a pull request must pass; install .github/rulesets/gates.json (README, Getting Started):\n${ + missing.map((name) => ` ${name}`).join('\n') + }`, + ) + Deno.exit(1) + } + console.log(`check-branch-rules: main's active rules require all ${REQUIRED_CHECKS.length} checks`) +} diff --git a/scripts/deno.json b/scripts/deno.json index 8e7df68..d41a352 100644 --- a/scripts/deno.json +++ b/scripts/deno.json @@ -3,6 +3,7 @@ "strict": true, "noImplicitOverride": true }, + "nodeModulesDir": "manual", "imports": { "@std/assert": "jsr:@std/assert@1", "@std/cli/parse-args": "jsr:@std/cli@1/parse-args",