Shared CI and deploy pipeline for a small fleet of Astro sites on Cloudflare Workers, and the bun tools around them. One set of reusable GitHub Actions workflows and composite actions keeps lint, type checks, license policy, Playwright and axe-core accessibility tests, Unlighthouse budgets, and deploys identical across every site.
- SHA-pinned. Every third-party action in this repo is pinned to a full commit SHA with a version comment.
- Audited. Workflows are checked with zizmor, jactionlint, check-jsonschema and shellcheck on every change (see Self-validation).
- In production. It runs the pipelines for the sites listed under Used by, including the marketing site for RavenFlight Industries, LLC.
It is semi-opinionated: the defaults match how my own sites are built (bun, Astro, Playwright).
- taraxvoid.net, my portfolio
- Queer Omaha, Synth Omaha and Soundry, free community sites for Omaha's queer and music scenes
- ravenflight.io, RavenFlight's marketing site
Optional. Paste this into your site's README. It follows light and dark mode.
<a href="https://github.com/taraxvoid/voidflow">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://img.shields.io/badge/CI-voidflow-8b8fd9?labelColor=2b2d3a&style=flat">
<img alt="CI: voidflow" src="https://img.shields.io/badge/CI-voidflow-5a5fb8?labelColor=3a3d4d&style=flat">
</picture>
</a>Point uses in your configuration at a workflow, e.g. in your .github/workflows/ci.yml. Pin to a release commit SHA with the version in a comment, the same way you would any third-party action (see Versioning).
name: CI
on:
pull_request:
branches: [main, next, live]
types: [opened, synchronize, reopened, ready_for_review]
workflow_dispatch:
jobs:
validate:
uses: taraxvoid/voidflow/.github/workflows/site-ci.yml@15caea203066d42cb7be021eedb6356fac6ffdec # v0.8.1Pass runner (defaults to GitHub runner ubuntu-latest)
jobs:
validate:
uses: taraxvoid/voidflow/.github/workflows/site-ci.yml@15caea203066d42cb7be021eedb6356fac6ffdec # v0.8.1
with:
runner: my-hosted-runnerSites with one production environment and no next/live branches (main
deploys to prod, pull requests get previews) pass single-environment: true,
the same flag resolve-env and branch-env-map take:
on:
push:
branches: [main]
pull_request:
branches: [main]
types: [opened, synchronize, reopened, ready_for_review]
workflow_dispatch:
jobs:
validate:
uses: taraxvoid/voidflow/.github/workflows/site-ci.yml@<sha> # <version>
with:
single-environment: truemain then gets the production test tier (test:e2e:all, test:e2e:a11y,
test:e2e:lighthouse) instead of only test:e2e:full. Include the push
trigger so a direct push to main is validated too; the e2e steps resolve the
branch from the pushed ref.
jobs:
validate:
uses: taraxvoid/voidflow/.github/workflows/site-ci.yml@<sha> # <version>
with:
package-manager: pnpm # default: bun
e2e-extra-paths: '["wrangler.jsonc", "site.vars.json"]' # JSON array of globs
build-env: ${{ format('{{"PUBLIC_SITE_URL":"https://example.com","GIT_SHA":"{0}"}}', github.sha) }}With pnpm, the pnpm version comes from packageManager and Node from
engines in package.json. The lockfile (bun.lock or pnpm-lock.yaml)
follows the package manager. build-env is a JSON object, visible in logs, so
no secrets.
A bun site can set a security scanner in bunfig.toml, which checks every
package on install:
[install.security]
scanner = "@socketsecurity/bun-security-scanner"CI skips it by default. The scanner calls a third-party API on every install,
so a network blip or an API change could fail a build that otherwise passes,
and rerunning the same commit could give a different result. site-ci.yml
installs with a copy of bunfig.toml minus the scanner line (every other
setting, e.g. minimumReleaseAge, still applies) and leaves a notice in the
run. Local bun install reads bunfig.toml as is, so the scanner still runs
on your machine.
To run it in CI too, pass socket-scanner: true to site-ci.yml. A workflow
that installs on its own (outside site-ci.yml) gets the same default from
the composite action:
- uses: oven-sh/setup-bun@<sha> # <version>
- uses: taraxvoid/voidflow/actions/bun-install@<sha> # <version>
# with: { socket-scanner: 'true' }The copy is checked by parsing both files: it must equal your bunfig.toml
with only install.security.scanner removed. Set the scanner as a plain
scanner = "..." line (or the dotted security.scanner form); if it can't be
removed cleanly, e.g. an inline table, the install fails rather than run or
skip the scanner silently. pnpm sites are unaffected.
Runs lint, typecheck, check for GPL licenses, e2e with Playwright.
site-ci.yml runs these package.json scripts: lint, check, build,
test:unit, check:licenses, test:e2e:full (PRs into main),
test:e2e:all (PRs into next and live), test:e2e:a11y (next and
live), and test:e2e:lighthouse (live). A script the site does not define
is skipped with a notice, so none of them is required.
tool-ci.yml is the validation job for bun tools and libraries (CLIs, TUIs,
packages): gitleaks over the full history, install (with the same Socket
scanner handling as site-ci.yml), tools from the repo's mise.toml when it
has one, then whichever of lint, check, build and test package.json
defines, and check:licenses when dependencies or scripts/ changed. When
workflows change it also runs jactionlint (same profile handling as
site-ci.yml) and
zizmor. The bun version comes from
.bun-version when present.
name: CI
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pull-requests: read
jobs:
validate:
uses: taraxvoid/voidflow/.github/workflows/tool-ci.yml@<release SHA> # vX.Y.ZThe job reports as validate / Validate, the check the tool() entry in
rulesets/index.ts requires.
site-ci.yml runs bun run test:e2e:lighthouse on PRs into live. Sites
implement that script with the shared runner here, which serves the built
static directory on a free port and runs
Unlighthouse against it, reusing Playwright's
Chromium. It exits non-zero if a category budget in the site's config fails.
Add @taraxvoid/voidflow as a devDependency and call the bin from the site's
package.json:
"test:e2e:lighthouse": "bun run build && unlighthouse-runner --dir dist"Options: --dir (default dist, use dist/client for Cloudflare adapter
builds), --config (default unlighthouse.config.ts), --version (pinned
Unlighthouse version). Start from
scripts/unlighthouse/unlighthouse.config.example.ts
and add .unlighthouse/ to the site's .gitignore.
site-ci.yml runs bun run check:licenses when the lockfile or scripts/
change. Sites implement that script with the shared check, which runs
license-checker-rseidelsohn in the site's repo root and fails on GPL, AGPL,
SSPL and similar copyleft licenses (LGPL is allowed). Voidflow pins the
checker to an exact version as its own dependency, so sites don't need to
install it and every site runs the same version (sites that added
license-checker-rseidelsohn as a devDependency for 0.12.2 can drop it).
With @taraxvoid/voidflow as a devDependency, call the bin from the site's
package.json:
"check:licenses": "check-licenses"Don't use bunx --package @taraxvoid/voidflow@<version> ... for these. It
duplicates the version pin outside the lockfile and Renovate, and bunx reads
the global ~/.bunfig.toml rather than the site's, so a global
minimumReleaseAge that doesn't exclude @taraxvoid/voidflow blocks it
locally (including in the pre-commit hook) for 3 days after every release.
The devDependency's bins are installed under the site's own bunfig.toml.
Both bins (check-licenses, unlighthouse-runner) are TypeScript run by Bun
(#!/usr/bin/env bun), so bun must be on PATH even when the site uses
npm or pnpm. The @taraxvoid/voidflow/playwright export is plain ESM and runs
on Node 22+ without Bun.
Shared playwright.config for Astro sites that run e2e against
astro preview. It picks a free port outside CI, sets
ASTRO_PREVIEW_BACKGROUND=false (Astro backgrounds preview in agent
environments, which makes Playwright think the server exited), and drops the
webServer when PLAYWRIGHT_BASE_URL is set.
bun add -d @taraxvoid/voidflow # or: pnpm add -D @taraxvoid/voidflow// playwright.config.js
import { definePreviewConfig } from '@taraxvoid/voidflow/playwright'
export default definePreviewConfig({ ciPort: 4141 })The same release is also published unscoped as voidflow (identical code and
version, with a short pointer README), so bun add -d voidflow and
import ... from 'voidflow/playwright' work too. @taraxvoid/voidflow is the
canonical name.
Options: ciPort (required in CI), runner (default bun, e.g. pnpm),
timeout (default 30000), projects (default Pixel 7 and Desktop Chrome),
testDir (default ./test/e2e). Requires @playwright/test in the site.
biome/base.json is the house style for the fleet: 4-space
indent, LF, width 80, single quotes, semicolons only where needed, trailing
commas, the recommended rule preset, organized imports, CSS lint off, and
unused-import/variable rules off in .astro files (Biome can't see template
usage). With @taraxvoid/voidflow as a devDependency, the site's biome.json
extends it and adds only what is specific to the site:
{
"$schema": "https://biomejs.dev/schemas/2.5.14/schema.json",
"extends": ["@taraxvoid/voidflow/biome"],
"files": {
"includes": ["**", "!**/bun.lock", "!public/admin/**"]
}
}Lists such as files.includes and overrides combine with the base's rather
than replace them; single values set in the site win. The base has no ignore
lists, so each site keeps its own. It needs Biome 2: the pre-commit hook and
site-ci.yml print a warning (they don't fail) when a site has Biome 1.x
installed.
default.json is the shared Renovate preset. Sites add a
renovate.json:
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["github>taraxvoid/voidflow"]
}It builds on config:best-practices (which pins GitHub Action digests), holds
updates for 3 days like minimumReleaseAge in bunfig.toml (except our own
package and security alerts), uses chore(deps): Conventional Commits, groups
actions, astro, biome and playwright, and runs on Monday mornings. Custom
managers keep three things current that Dependabot cannot see: the
@taraxvoid/voidflow@x.y.z pin inside package.json scripts, a lefthook
remote's ref tag, and tool versions in a justfile (annotate the line above
with # renovate: datasource=... depName=...). Requires the Renovate GitHub App
on the repo.
lefthook/base.yml is the shared hook set for any bun
(or pnpm) repo, sites and tools alike, consumed as a lefthook remote. It calls
the repo's own package.json scripts (<pm> run --if-present, so a missing
script is skipped; <pm> is pnpm when the repo has a pnpm-lock.yaml, bun
otherwise):
pre-commit: dependency guard, Biome 1.x warning,format(formatted files are re-staged),lint:actions,check,test:unit, andcheck:licenses/auditwhen the lockfile changed.commit-msg: advisory Conventional Commits hint (never blocks).pre-push: bail if the remote branch is ahead, thentest:push; skipped when the push carries no commit the remote lacks (branch deletes, and tags or branches at already-pushed commits).
Before test:unit and test:push, the hooks unset the variables git exports to
hooks (GIT_DIR, GIT_INDEX_FILE, ...; the list comes from
git rev-parse --local-env-vars). Without that, a test that runs
git -C <tmpdir> commit operates on, and commits into, the repo being hooked.
# lefthook.yml in the repo
remotes:
- git_url: https://github.com/taraxvoid/voidflow
ref: v0.10.0 # a tag or branch; lefthook passes it to `git clone --branch`
configs:
- lefthook/base.yml
Upgrading from lefthook/site.yml (v0.13 and earlier): change the configs
entry to lefthook/base.yml when you bump ref. The hooks are otherwise the
same. lefthook/site.yml is now a stub whose pre-commit and pre-push fail
with that instruction, so a ref bump (for example from Renovate) that leaves
configs alone stops commits instead of turning the hooks off silently.
Lefthook merges lefthook.yml, then remotes, then lefthook-local.yml, so a
repo's own lefthook.yml can add commands (for example image compression) but
cannot override ones defined here. Differences go through package.json
scripts: define test:unit:precommit (for example excluding tests that need a
built dist/) and the pre-commit unit step runs it instead of test:unit.
Likewise, define check:precommit and the typecheck step runs it instead of
check. CI keeps calling the full scripts, so these only narrow what runs
locally. Common uses:
- bun:
"test:unit:precommit": "bun test --changed"runs only the test files whose imports reach an uncommitted change. - pnpm workspace:
pnpm -r --filter '...[HEAD]' run <script>runs only the changed packages and their dependents. Fall back to the full run when a root-level file (lockfile, shared tsconfig) changed, since those don't belong to any package. If the rootpackage.jsondefines the same script as a recursive run, add--filter '!{.}'so it doesn't recurse.
Hook-level options merge too. Don't set parallel: true under pre-commit: in
the repo's lefthook.yml: it applies to the merged hook, so every step,
including the shared ones, starts at once and their priority order is ignored
(tests that share a database or build output can then time out).
Install the binaries through mise. A starter mise.toml for a repo using
these hooks and voidflow CI:
[tools]
lefthook = "2.2.1"
gitleaks = "8.30.1"
jactionlint = "2.1.0"Renovate bumps mise.toml natively. Set the prepare script so bun install
wires the hooks, but make it tolerate a missing binary:
"prepare": "lefthook install || echo 'lefthook not installed (run mise install); git hooks skipped'"A bare lefthook install fails the whole install step (exit 127) on any runner
without the lefthook binary, which includes GitHub-hosted and self-hosted CI
images, and CI never runs git hooks anyway. ref cannot be a commit SHA; pin a
release tag.
Branch protection for the fleet lives in rulesets/index.ts:
one canonical main / next / live definition (plus prerelease and maintenance
tiers for repos with release channels) and a small per-repo table
(Netlify or not, check names, CODEOWNERS, extra bypass actors). Sites use
site() and tools use tool() (main only, no Netlify, requires
validate / Validate from tool-ci.yml). Private repos are left out: on a
plan without rulesets for private repos, the API returns 403. Hand-edited
rulesets drift, and drift is how a next ruleset ends up requiring a check
name that no workflow reports any more.
just rulesets-plan # read-only: diff every repo against the config
just rulesets-plan --repo soundry
just rulesets-plan --check # exit 1 on drift, for CI
just rulesets-apply # create/update rulesets to match
just rulesets-apply --prune # also delete rulesets a repo lists under `retire`Single-environment sites (a stable main that deploys to prod) manage only the
main tier; their old next/live rulesets are listed under retire. voidflow itself
manages main, prerelease (next) and maintenance (release/*) and sets
linearHistory: false so next can be promoted into main with a merge commit.
Uses the gh CLI for auth (needs admin on the repos). Rulesets that are not in
the config are reported and left alone; only retire entries are ever deleted,
and only with --prune. Private repos on a plan without rulesets (GitHub answers
403, e.g. a free personal account) are skipped and listed at the end rather than
failing the run, so --check stays green and they pick up their rulesets once the
repo goes public or the plan changes. It is repo tooling, not part of the published
package.
Example deployment job added to your workflow above. resolve-env maps the
branch to dev/staging/prod once, up front, so its output can be used both to
drive the deploy and to label the job (Deploy [dev], Deploy [staging],
Deploy [prod]). A job's name: can reference needs.<job>.outputs.* but
not a step output from within itself, hence the split:
jobs:
resolve-env:
needs: validate
if: github.event_name == 'push'
uses: taraxvoid/voidflow/.github/workflows/resolve-env.yml@15caea203066d42cb7be021eedb6356fac6ffdec # v0.8.1
deploy:
name: Deploy [${{ needs.resolve-env.outputs.env }}]
needs: resolve-env
if: github.event_name == 'push'
environment: ${{ needs.resolve-env.outputs.env }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: taraxvoid/voidflow/actions/cloudflare-deploy@15caea203066d42cb7be021eedb6356fac6ffdec # v0.8.1
with:
env: ${{ needs.resolve-env.outputs.env }}
cloudflare-api-token: ${{ secrets.CLOUDFLARE_API_TOKEN }}Sites with a single production environment and per-PR previews (no next or
live branch) pass with: { single-environment: true } to resolve-env.yml
(or the same input on actions/branch-env-map). main then maps to prod and
pull_request events map to preview; any other ref fails.
Semver, released with release-please
(release-please.yml) on three branch-based channels
(full flow in RELEASING.md):
| Channel | Branch | Example | npm dist-tag |
|---|---|---|---|
| stable | main |
0.10.0 |
latest |
| prerelease | next |
0.10.0-next, 0.10.0-next.1 |
next |
| maintenance | release/N.x |
0.9.4 |
release-N.x |
release-please keeps a standing release PR open on each channel's branch, updated as commits land.
Merging it bumps package.json (and CHANGELOG.md on stable), tags vX.Y.Z (vX.Y.Z-next[.N] on next),
creates the GitHub release and publishes to npm and JSR under the channel's dist-tag. The PR is
opened with the org's release-bot GitHub App token (RELEASE_BOT_CLIENT_ID /
RELEASE_BOT_APP_PRIVATE_KEY) so CI runs on it.
Feature PRs target next; promotion to main is a merge-commit PR (just promote).
Commit messages should follow Conventional Commits,
enforced loosely, as an advisory commit-msg hint (see
scripts/check-commit-msg.sh), not a blocking check. They
drive the version bump and generated changelog.
Ways to consume a release:
- Floating major tag:
@v0follows the newest stable 0.x release. Easiest to track, and mutable like any floating tag. - SHA pin with a version comment, e.g.
@15caea2... # v0.8.1: what I use. Same hardening as any third-party action, and Renovate or Dependabot can bump it. - Try unreleased changes: pin the commit SHA of a
vX.Y.Z-next/vX.Y.Z-next.Nprerelease tag.
Treat @main and @next as unstable.
This repo validates its own workflows and composite actions (.github/workflows/ci.yml):
- jactionlint: workflow schema/logic; also shellchecks
run:steps in.github/workflows/*.ymlautomatically (shellcheckships onubuntu-latest) - check-jsonschema: schema-checks
actions/**/action.ymland the workflow files against SchemaStore'sgithub-action.json/github-workflow.json - shellcheck (
scripts/shellcheck-actions.ts): checksrun:steps insideactions/**/action.yml, which jactionlint doesn't reach - zizmor: security audit (unpinned refs, script injection via
${{ }}inrun:, excess permissions, etc.)
mise install # tools pinned in mise.toml
bun install # its prepare script runs `lefthook install`just validate: run everything CI runs, locallyjust lint-workflows/lint-actions/lint-shell/security: run one check at a timejust dry-run: full local run ofci.ymlviaact(needs Docker running); pass extra args, e.g.just dry-run -j validation- lefthook runs the fast checks (
lint-workflows,lint-actions,lint-shell) onpre-commit, andzizmoronpre-push
Colima users: .actrc already disables the docker-socket mount (--container-daemon-socket -). act binds /var/run/docker.sock by default, which doesn't exist under Colima and fails with operation not supported. None of these workflow steps need docker-in-docker, so this is safe.
Run just dry-run from a normal checkout, not a linked git worktree. act copies the working tree via docker cp rather than a real clone, so a worktree's .git pointer back to the parent repo doesn't resolve inside the container, so git ls-files-based steps (schema validation, scripts/shellcheck-actions.ts) silently see zero files instead of erroring. Verified clean end-to-end (🏁 Job succeeded) from a plain clone.
Provided as is under the MIT license. Issues and pull requests are welcome.