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
42 changes: 42 additions & 0 deletions .github/rulesets/gates.json
Original file line number Diff line number Diff line change
@@ -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" }
]
}
}
]
}
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:

Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"@systemfsoftware/stryker-js": "catalog:",
"@systemfsoftware/tsconfig": "catalog:",
"@types/node": "catalog:",
"effect": "catalog:",
"vitest": "catalog:",
"lint-staged": "catalog:",
"oxlint": "catalog:",
Expand All @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

85 changes: 85 additions & 0 deletions scripts/check-branch-rules.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
87 changes: 87 additions & 0 deletions scripts/check-branch-rules.ts
Original file line number Diff line number Diff line change
@@ -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<typeof StatusChecksRule>

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<typeof BranchRules>[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`)
}
1 change: 1 addition & 0 deletions scripts/deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading