Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
ba4c355
chore: back-merge v0.13.0 into next
github-actions[bot] Oct 12, 2026
003710d
Merge pull request #154 from taraxvoid/backmerge/v0.13.0
ravenflight-releases[bot] Oct 12, 2026
6192a43
fix(site-ci): replace deprecated actionlint with jactionlint (#174)
taraxvoid Oct 15, 2026
e775c8b
feat: tool ergonomics (base hooks, tool-ci, mise, tool rulesets) (#157)
taraxvoid Oct 16, 2026
a5f0977
feat(biome): shared base config sites extend as @taraxvoid/voidflow/b…
taraxvoid Oct 16, 2026
3f22479
chore: remove committed mise cache and stray file from #157 (#184)
taraxvoid Oct 16, 2026
1888739
chore(next): release 0.14.0-next (#176)
ravenflight-releases[bot] Oct 16, 2026
3ff2a33
feat(ci): SARIF for gitleaks and zizmor, opt-in upload-sarif.yml (#187)
taraxvoid Oct 17, 2026
e7453cc
docs: spec <usecase>-ci workflows and fleet sync (#188)
taraxvoid Oct 17, 2026
8e75d8b
chore(next): release 0.14.0-next.1 (#189)
ravenflight-releases[bot] Oct 22, 2026
27c1381
chore: back-merge v0.14.0 into next
taraxvoid Oct 23, 2026
6b04798
Merge pull request #211 from taraxvoid/worktree-backmerge-v0.14.0
taraxvoid Oct 23, 2026
7bdc5bc
chore: back-merge v0.15.0 from main into next
taraxvoid Oct 24, 2026
7b60532
Merge pull request #220 from taraxvoid/worktree-219-backmerge-v0.15.0
taraxvoid Oct 24, 2026
42c5373
feat(cf-tokens): implement sync-able scoped Cloudflare deploy tokens …
taraxvoid Oct 24, 2026
cc8bf68
chore: back-merge v0.15.1 into next
github-actions[bot] Oct 24, 2026
e6ae0df
Merge pull request #225 from taraxvoid/backmerge/v0.15.1
ravenflight-releases[bot] Oct 24, 2026
553ef13
chore(next): release 0.16.0-next (#223)
ravenflight-releases[bot] Oct 24, 2026
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
31 changes: 30 additions & 1 deletion .github/workflows/site-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,10 @@ jobs:
"https://github.com/gitleaks/gitleaks/releases/download/v${version}/${tarball}"
echo "${sha} $RUNNER_TEMP/$tarball" | sha256sum -c -
tar -xzf "$RUNNER_TEMP/$tarball" -C "$RUNNER_TEMP" gitleaks
"$RUNNER_TEMP/gitleaks" git --redact --no-banner --verbose .
# Redacted SARIF for upload-sarif.yml; the scan still fails on findings.
mkdir -p "$RUNNER_TEMP/sarif"
"$RUNNER_TEMP/gitleaks" git --redact --no-banner --verbose \
--report-format sarif --report-path "$RUNNER_TEMP/sarif/gitleaks.sarif" .
shell: bash

- name: Check package manager
Expand Down Expand Up @@ -468,3 +471,29 @@ jobs:
path: |
playwright-report/
test-results/

# SARIF for upload-sarif.yml. Runs after failures too, so findings that
# failed this job still reach code scanning. zizmor scans every workflow
# (not just changed ones) because code scanning closes any alert missing
# from the next upload; with --format sarif it never fails the job.
- name: Setup uv (SARIF)
if: ${{ !cancelled() }}
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1

- name: zizmor SARIF
if: ${{ !cancelled() && hashFiles('.github/workflows/*.yml', '.github/workflows/*.yaml') != '' }}
run: |
mkdir -p "$RUNNER_TEMP/sarif"
uvx zizmor@1.30.1 --format sarif .github > "$RUNNER_TEMP/sarif/zizmor.sarif"
shell: bash
env:
GH_TOKEN: ${{ github.token }}

- name: Keep SARIF
if: ${{ !cancelled() }}
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: voidflow-sarif
path: ${{ runner.temp }}/sarif/*.sarif
if-no-files-found: ignore
retention-days: 7
31 changes: 30 additions & 1 deletion .github/workflows/tool-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,10 @@ jobs:
"https://github.com/gitleaks/gitleaks/releases/download/v${version}/${tarball}"
echo "${sha} $RUNNER_TEMP/$tarball" | sha256sum -c -
tar -xzf "$RUNNER_TEMP/$tarball" -C "$RUNNER_TEMP" gitleaks
"$RUNNER_TEMP/gitleaks" git --redact --no-banner --verbose .
# Redacted SARIF for upload-sarif.yml; the scan still fails on findings.
mkdir -p "$RUNNER_TEMP/sarif"
"$RUNNER_TEMP/gitleaks" git --redact --no-banner --verbose \
--report-format sarif --report-path "$RUNNER_TEMP/sarif/gitleaks.sarif" .
shell: bash

- uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
Expand Down Expand Up @@ -211,3 +214,29 @@ jobs:
- name: Check licenses
if: steps.scripts.outputs.check_licenses == 'true' && (steps.changes.outputs.lockfile == 'true' || steps.changes.outputs.manifest == 'true' || steps.changes.outputs.scripts == 'true')
run: bun run check:licenses

# SARIF for upload-sarif.yml. Runs after failures too, so findings that
# failed this job still reach code scanning. zizmor scans every workflow
# (not just changed ones) because code scanning closes any alert missing
# from the next upload; with --format sarif it never fails the job.
- name: Setup uv (SARIF)
if: ${{ !cancelled() }}
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1

- name: zizmor SARIF
if: ${{ !cancelled() && hashFiles('.github/workflows/*.yml', '.github/workflows/*.yaml') != '' }}
run: |
mkdir -p "$RUNNER_TEMP/sarif"
uvx zizmor@1.30.1 --format sarif .github > "$RUNNER_TEMP/sarif/zizmor.sarif"
shell: bash
env:
GH_TOKEN: ${{ github.token }}

- name: Keep SARIF
if: ${{ !cancelled() }}
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: voidflow-sarif
path: ${{ runner.temp }}/sarif/*.sarif
if-no-files-found: ignore
retention-days: 7
65 changes: 65 additions & 0 deletions .github/workflows/upload-sarif.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
name: Upload SARIF

# Uploads the gitleaks and zizmor SARIF that site-ci.yml and tool-ci.yml keep
# as the `voidflow-sarif` artifact, so findings show up in code scanning (and
# in voidlight's counts) instead of only failing a run.
#
# A separate workflow because uploading needs `security-events: write`: a
# reusable workflow can't ask for more than its caller grants, so adding it to
# site-ci/tool-ci would break every caller that doesn't grant it. Opt in by
# adding a second job to the caller:
#
# sarif:
# needs: validate
# if: ${{ !cancelled() && needs.validate.result != 'skipped' }}
# permissions:
# contents: read
# actions: read
# security-events: write
# uses: taraxvoid/voidflow/.github/workflows/upload-sarif.yml@<sha> # <version>
#
# Code scanning is free on public repositories; private ones need GitHub Code
# Security, without which the upload fails.

on:
workflow_call:
inputs:
runner:
description: Runner label. Defaults to a GitHub-hosted runner.
type: string
default: ubuntu-latest

permissions:
contents: read
actions: read
security-events: write

jobs:
upload:
name: Upload SARIF
runs-on: ${{ inputs.runner }}
timeout-minutes: 5
steps:
- name: Fetch SARIF
id: fetch
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
# A validate run that stopped before keeping SARIF leaves nothing to upload.
continue-on-error: true
with:
name: voidflow-sarif
path: sarif

# One category per tool, so each upload replaces only its own results.
- name: Upload gitleaks
if: ${{ steps.fetch.outcome == 'success' && hashFiles('sarif/gitleaks.sarif') != '' }}
uses: github/codeql-action/upload-sarif@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
with:
sarif_file: sarif/gitleaks.sarif
category: gitleaks

- name: Upload zizmor
if: ${{ steps.fetch.outcome == 'success' && hashFiles('sarif/zizmor.sarif') != '' }}
uses: github/codeql-action/upload-sarif@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
with:
sarif_file: sarif/zizmor.sarif
category: zizmor
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ node_modules/
.env.*
.env.fish
!.env.example
fnox.local.toml

# Editor / bots
.poolside/
Expand All @@ -15,7 +16,7 @@ node_modules/
.vscode/
.idea
.superpowers
docs/superpowers
docs/

# mise state from runs with HOME at the repo root
/.cache/
Expand Down
2 changes: 1 addition & 1 deletion .release-please/next-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "0.13.0"
".": "0.16.0-next"
}
172 changes: 172 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,175 @@ 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.

## Cloudflare deploy tokens as code

[`cloudflare/tokens.ts`](cloudflare/tokens.ts) holds the config shape and the
plan logic; the fleet (repos, accounts, envs) lives in the org's own infra
repo (see [Fleet config](#fleet-config---config)), one token per repo and
env. Each token is owned by its env's account and scoped to
one Worker (the Workers Editor role on it), plus Workers Routes Write on the
zones of its routes only, with a 90-day expiry. An env whose Worker lives in
another account (say a separate preview Worker kept apart from prod) sets its
own `account`; otherwise it uses the repo's.

The Worker and zones come from the repo's own wrangler config on its default
branch (`wrangler.jsonc`, or the env's `wrangler` path), read the way wrangler
reads it: the `env.<name>` block named like the env if there is one (or
`wranglerEnv`), else the top level. Each route's host maps to the account zone
it sits in, so a route added to wrangler shows up as drift on the next plan,
and a host in none of the account's zones fails the plan. An env can still
spell out `worker` and `zones` instead; `zones: []` grants none, e.g. for a
Worker Preview token, which deploys no routes.

### Where the secret goes

By default each env's token goes into an environment secret,
`CLOUDFLARE_API_TOKEN` in the GitHub environment named like the env. Only
jobs with `environment: <env>` read it, so the environment's deployment
branch policy (`main` only for `prod`) is what keeps a pull request job from
reading the prod token. That is the recommended shape: one deploy job for
`main` with `environment: prod`, one for pull requests with
`environment: preview`.

```ts
envs: [
{ env: 'prod' }, // Worker and zones from wrangler.jsonc
{ env: 'preview', zones: [] }, // Worker Previews: same Worker, no routes
],
```

A repo that deploys without environments sets `secret: 'repo'` on one env: its
token goes into the repository secret, which every job reads. Only one env
per repo can do that. When every env uses environments, a plan reports a
leftover repository `CLOUDFLARE_API_TOKEN` (a pre-voidflow token that jobs
without an environment still deploy with), and `--prune` deletes it along
with the `retire` tokens.

A Worker Preview runs on the same Worker as prod, so unless Cloudflare grows a
preview-only permission, the preview token can deploy to prod too. Keeping it
in its own environment still scopes and rotates it on its own.

The targets need `CF_TOKEN_ADMIN`: a user-owned token (account-owned tokens
reach only their own account) that includes every account in the config, with
Account API Tokens Edit, Workers Scripts Read and Zone Read on each, a short
expiry and an IP filter. Adding a repo in a new account means adding that
account to this token. It can mint
deploy tokens, so it stays on a maintainer's machine and never goes into CI.
[fnox](https://github.com/jdx/fnox) (installed by `mise install`) supplies
it: the committed [`fnox.toml`](fnox.toml) declares the secret, but no value,
and a `cf-admin` provider backed by the macOS Keychain. Store the value once
with `fnox set -k voidflow-cf-token-admin CF_TOKEN_ADMIN` (it prompts with
hidden input), and confirm with `just doctor`. To use another fnox provider
(age, 1Password, `pass`...), redefine `[providers.cf-admin]` in a gitignored
`fnox.local.toml` at the repo root (one per checkout or worktree). If
`CF_TOKEN_ADMIN` is already in the environment, the targets use it and skip
fnox.

From the infra repo, whose recipes pass its config to these:

```sh
just cf-tokens-plan # read-only: diff every repo's tokens against the config
just cf-tokens-plan --check # exit 1 on drift or a token within 14 days of expiry
just cf-tokens-apply # create/update, rotate tokens due, set CLOUDFLARE_API_TOKEN per env
just cf-tokens-apply --rotate # rotate every token now
just cf-tokens-apply --prune # also delete `retire` tokens and a leftover repo-wide secret
just cf-tokens-groups # live names of the permission group ids the config uses
```

The plan prints when each token was last used, so a CI deploy shows up there.
To prove a token's policies are enough before CI depends on them, run the
deploy with a throwaway token that has the same policies. From the site's
checkout:

```sh
just -f ../infra/justfile cf-tokens-try transbot prod bunx wrangler deploy
```

The real token can't be used locally (its value only exists in GitHub), and
a shell holding a wider token (an admin token, `wrangler login`) would hide a
missing grant. The command gets the throwaway token as `CLOUDFLARE_API_TOKEN`,
with `CF_TOKEN_ADMIN` removed, and the token is deleted afterwards (it
expires within the hour regardless).

A plan needs `gh` as well as `CF_TOKEN_ADMIN`: it reads each repo's wrangler
config and secret names. New values go straight from the API into `gh secret
set` and are never printed. A rotation creates the new token, sets the
secret, then deletes the old token, so a deploy in between still works. If
the secret can't be set, the new token is deleted again. A run that stops
partway can leave two tokens with one name, and nothing says which one GitHub
holds, so the next run rotates: a fresh token into the secret, then both old
ones deleted. Policy drift is fixed in place without changing the value.

Limits worth knowing:

- Per-Worker grants apply only to Workers that exist. A Worker's first deploy
needs an admin token; the tool refuses to plan for a Worker it cannot find.
- Custom Domains do not support per-Worker roles yet. If a deploy fails on the
domain with only the zone grant, add `fallback: { reason, since }` to that
env. Its token then also gets account-wide Workers Scripts Write, every plan
prints it, and `apply` sets the variable `CF_TOKEN_FALLBACK` next to the
secret. Pass that to the deploy action so every deploy warns:

```yaml
- uses: taraxvoid/voidflow/actions/cloudflare-deploy@<sha> # <version>
with:
env: prod
cloudflare-api-token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
token-fallback: ${{ vars.CF_TOKEN_FALLBACK }}
```

Cut over one repo at a time: `just cf-tokens-apply --repo <name>`, deploy, then
`just cf-tokens-apply --repo <name> --prune` to delete its old token. Repo
tooling, not part of the published package.

## Fleet config (`--config`)

voidflow holds the tools; each org keeps its fleet map (repo names, account
ids, envs) in its own private infra repo, e.g. `taraxvoid/infra`, as modules
that export `REPOS` (or a default). The infra repo's justfile wraps voidflow's
recipes with `--config` pointing at them. `cf-tokens-*` requires it;
`rulesets-*` still falls back to the built-in `REPOS` in `rulesets/index.ts`
until that moves out too.

```ts
// cf-tokens.ts in the org's infra repo
export const REPOS = {
'example-org/site': {
account: '<account id>',
envs: [
{ env: 'prod' }, // env.prod in its wrangler.jsonc, else the top level
{ env: 'preview', zones: [] },
],
retire: [],
},
}
```

```just
# justfile in the org's infra repo
voidflow := env_var_or_default("VOIDFLOW", home_directory() / "projects/personal/voidflow")
vf := "just -f " + quote(voidflow / "justfile")
tokens := justfile_directory() / "cf-tokens.ts"

cf-tokens-plan *args:
{{ vf }} cf-tokens-plan --config {{ quote(tokens) }} {{ args }}

# [no-cd]: the command runs in the site checkout `just -f` was called from
[no-cd]
cf-tokens-try repo env +cmd:
{{ vf }} cf-tokens-try {{ quote(tokens) }} {{ repo }} {{ env }} {{ cmd }}
```

A rulesets config can use the tier model, where any field left out takes
`tool()`'s value (main only, the `validate` check, CODEOWNERS, linear
history, so `{}` is a valid repo) and an unknown field is an error. Or it can
spell a repo's rulesets out in full as `{ rulesets: [...], retire: [...] }` when its protection doesn't fit
the tiers. A snapshot of what GitHub has now (`gh api
repos/<repo>/rulesets/<id>`, keeping `name`, `target`, `enforcement`,
`conditions`, `bypass_actors` and `rules`) plans with no drift.

A relative path resolves from the directory `just` runs in. Each config is
checked before any API call, and every problem is reported by its path.
`CF_TOKEN_ADMIN` then needs to cover that org's accounts, and `gh` needs admin
on its repos.
Loading
Loading