Skip to content
taraxvoidPublic

About

Enter a flow state of security, stability, and developer ergonomics

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

voidflow

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).

Used by

Show you use it

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>

Usage

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.1

Non-GitHub / self-hosted runners

Pass 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-runner

Single-environment sites

Sites 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: true

main 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.

pnpm sites, build env, extra e2e paths

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.

Install-time security scanner (Socket)

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.

Workflow Types

Validation / Checks

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 validation

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.Z

The job reports as validate / Validate, the check the tool() entry in rulesets/index.ts requires.

Unlighthouse (performance / SEO budgets)

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.

License policy

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.

Playwright preview config

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.

Shared Biome config

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.

Dependency updates (Renovate)

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.

Git hooks (lefthook)

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, and check:licenses / audit when the lockfile changed.
  • commit-msg: advisory Conventional Commits hint (never blocks).
  • pre-push: bail if the remote branch is ahead, then test: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 root package.json defines 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 rulesets as code

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.

Deployments

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.

Versioning

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: @v0 follows 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.N prerelease tag.

Treat @main and @next as unstable.

Self-validation

This repo validates its own workflows and composite actions (.github/workflows/ci.yml):

  • jactionlint: workflow schema/logic; also shellchecks run: steps in .github/workflows/*.yml automatically (shellcheck ships on ubuntu-latest)
  • check-jsonschema: schema-checks actions/**/action.yml and the workflow files against SchemaStore's github-action.json / github-workflow.json
  • shellcheck (scripts/shellcheck-actions.ts): checks run: steps inside actions/**/action.yml, which jactionlint doesn't reach
  • zizmor: security audit (unpinned refs, script injection via ${{ }} in run:, excess permissions, etc.)

Local dev setup

mise install   # tools pinned in mise.toml
bun install    # its prepare script runs `lefthook install`
  • just validate: run everything CI runs, locally
  • just lint-workflows / lint-actions / lint-shell / security: run one check at a time
  • just dry-run: full local run of ci.yml via act (needs Docker running); pass extra args, e.g. just dry-run -j validation
  • lefthook runs the fast checks (lint-workflows, lint-actions, lint-shell) on pre-commit, and zizmor on pre-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.

About

Enter a flow state of security, stability, and developer ergonomics

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Used by

Contributors

Languages