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
83 changes: 83 additions & 0 deletions .github/workflows/rulesets-drift.yml
Original file line number Diff line number Diff line change
@@ -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"
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 12 additions & 5 deletions rulesets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,18 +338,25 @@ 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 = <T>(xs: T[], key: (x: T) => string) =>
[...xs].sort((a, b) => key(a).localeCompare(key(b)))
return {
name: r.name,
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) {
Expand Down
5 changes: 4 additions & 1 deletion scripts/apply-rulesets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions test/rulesets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Ruleset>
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'])
Expand Down
Loading