diff --git a/.github/workflows/rulesets-drift.yml b/.github/workflows/rulesets-drift.yml new file mode 100644 index 0000000..c32b603 --- /dev/null +++ b/.github/workflows/rulesets-drift.yml @@ -0,0 +1,83 @@ +name: Ruleset drift + +# Runs `scripts/apply-rulesets.ts --check` (read-only) and puts the plan in the +# job summary. Red means a managed repo's branch rulesets differ from +# rulesets/index.ts: someone edited one by hand, or the config changed and was +# not applied (`just rulesets-apply`). Never applies or prunes from CI. +# +# Weekly, on demand, and on PRs that touch the config so a change shows its +# plan before merge. A PR that changes rulesets/ is expected to show drift +# until it merges and is applied, so read the summary rather than the colour. +# +# Reading rulesets on the other repos needs a token with `administration: read` +# on them; GITHUB_TOKEN is scoped to this repo only. This uses the read-only +# `drifter` App (DRIFTER_CLIENT_ID variable / DRIFTER_APP_PRIVATE_KEY secret, +# org-level). It needs only Administration (read) and must be installed on every +# repo listed below, or the token step fails. Keep that list in step with REPOS +# in rulesets/index.ts. +on: + schedule: + - cron: "17 6 * * 1" + workflow_dispatch: + pull_request: + paths: + - "rulesets/**" + - "scripts/apply-rulesets.ts" + - "scripts/lib/config.ts" + - ".github/workflows/rulesets-drift.yml" + +permissions: {} + +concurrency: + group: rulesets-drift-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + name: Check rulesets + # Secrets are not available to PRs from forks. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + no-cache: true + + - name: Generate App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.DRIFTER_CLIENT_ID }} + private-key: ${{ secrets.DRIFTER_APP_PRIVATE_KEY }} + owner: taraxvoid + repositories: | + queeromaha + soundry + synthomaha + taraxvoid.net + voidflow + permission-administration: read + + - name: Diff rulesets against rulesets/index.ts + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + set -euo pipefail + status=0 + bun scripts/apply-rulesets.ts --check > plan.txt 2>&1 || status=$? + cat plan.txt + { + echo '## Ruleset plan' + echo + echo '```' + cat plan.txt + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + exit "$status" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fce1345..0cc12d6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,6 +54,10 @@ just rulesets-apply --prune # also delete rulesets listed under `retire` Rulesets not in the config are reported and left alone. Repos where the API returns 403 (private repos on a plan without rulesets) are skipped and listed. +The `Ruleset drift` workflow runs `--check` weekly and on PRs touching +`rulesets/`, with the plan in the job summary. It is read-only; applying stays +a manual `just rulesets-apply`. + ## Cloudflare deploy tokens as code [`cloudflare/tokens.ts`](cloudflare/tokens.ts) holds the config shape and the diff --git a/rulesets/index.ts b/rulesets/index.ts index e333575..3b24eab 100644 --- a/rulesets/index.ts +++ b/rulesets/index.ts @@ -338,7 +338,7 @@ export function checkFleet(x: unknown): Fleet { // ---- normalisation + diff ------------------------------------------------- /** Order-insensitive canonical form so GitHub's ordering never shows as drift. */ -export function normalise(r: Ruleset): unknown { +export function normalise(r: Ruleset, opts: { bypass?: boolean } = {}): unknown { const sortBy = (xs: T[], key: (x: T) => string) => [...xs].sort((a, b) => key(a).localeCompare(key(b))) return { @@ -346,10 +346,17 @@ export function normalise(r: Ruleset): unknown { target: r.target, enforcement: r.enforcement, conditions: r.conditions, - bypass_actors: sortBy( - r.bypass_actors, - (a) => `${a.actor_type}:${a.actor_id}:${a.bypass_mode}`, - ), + // GitHub omits bypass_actors from tokens without write access to the + // repo (the read-only drift App), so the caller can drop the field + // from both sides rather than report it as drift. + ...(opts.bypass === false + ? {} + : { + bypass_actors: sortBy( + r.bypass_actors, + (a) => `${a.actor_type}:${a.actor_id}:${a.bypass_mode}`, + ), + }), rules: sortBy(r.rules, (x) => x.type).map((rule) => { const p = rule.parameters as any if (rule.type === 'required_status_checks' && p) { diff --git a/scripts/apply-rulesets.ts b/scripts/apply-rulesets.ts index b7d08c8..2f11d30 100755 --- a/scripts/apply-rulesets.ts +++ b/scripts/apply-rulesets.ts @@ -97,7 +97,10 @@ for (const repo of repos) { } matched.add(hit.id) const have: Ruleset = await gh([`repos/${repo}/rulesets/${hit.id}`]) - const lines = diff(normalise(have), normalise(ruleset)) + // A read-only token gets no bypass_actors back; compare the rest. + const bypass = Array.isArray(have.bypass_actors) + if (!bypass) console.log(` note "${ruleset.name}": bypass_actors not visible to this token, not compared`) + const lines = diff(normalise(have, { bypass }), normalise(ruleset, { bypass })) if (lines.length === 0) { console.log(` ok "${ruleset.name}"`) continue diff --git a/test/rulesets.test.ts b/test/rulesets.test.ts index b9b9fc2..ed60797 100644 --- a/test/rulesets.test.ts +++ b/test/rulesets.test.ts @@ -77,6 +77,16 @@ describe('normalise', () => { }) }) +describe('normalise without bypass_actors', () => { + test('a read-only API response (no bypass_actors) neither throws nor drifts when bypass is skipped', () => { + const want = build('main', REPOS['taraxvoid/queeromaha']) + const have = structuredClone(want) as Partial + delete have.bypass_actors + const opts = { bypass: false } + expect(diff(normalise(have as Ruleset, opts), normalise(want, opts))).toEqual([]) + }) +}) + describe('diff', () => { test('is empty for the same ruleset in a different order', () => { const a = build('next', REPOS['taraxvoid/queeromaha'])