voidflow 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tara \X/ Void
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,156 @@
1
+ # voidflow
2
+
3
+ Semi-opinionated GitHub Actions workflows for my personal sites and consultancy shop [RavenFlight Industries, LLC](https://rvnflt.com/github)
4
+
5
+ ## Usage
6
+
7
+ Point `uses` in your configuration to a workflow, e.g. in your `.github/workflows/ci.yml`
8
+
9
+ ```yaml
10
+ name: CI
11
+ on:
12
+ pull_request:
13
+ branches: [main, next, live]
14
+ types: [opened, synchronize, reopened, ready_for_review]
15
+ workflow_dispatch:
16
+
17
+ jobs:
18
+ validate:
19
+ uses: taraxvoid/voidflow/.github/workflows/site-ci.yml@main
20
+ ```
21
+
22
+ ### Non-GitHub / self-hosted runners
23
+
24
+ Pass `runner` (defaults to GitHub runner `ubuntu-latest`)
25
+
26
+ ```yaml
27
+ jobs:
28
+ validate:
29
+ uses: taraxvoid/voidflow/.github/workflows/site-ci.yml@main
30
+ with:
31
+ runner: my-hosted-runner
32
+ ```
33
+
34
+ ## Workflow Types
35
+
36
+ ### Validation / Checks
37
+
38
+ Runs lint, typecheck, check for GPL licenses, e2e with Playwright
39
+
40
+ ### Unlighthouse (performance / SEO budgets)
41
+
42
+ `site-ci.yml` runs `bun run test:e2e:lighthouse` on PRs into `live`. Sites
43
+ implement that script with the shared runner here, which serves the built
44
+ static directory on a free port and runs
45
+ [Unlighthouse](https://unlighthouse.dev/) against it, reusing Playwright's
46
+ Chromium. It exits non-zero if a category budget in the site's config fails.
47
+
48
+ In the site's `package.json` (pin the tag):
49
+
50
+ ```json
51
+ "test:e2e:lighthouse": "bun run build && bunx --package @taraxvoid/voidflow@0.6.0 unlighthouse-runner --dir dist"
52
+ ```
53
+
54
+ Options: `--dir` (default `dist`, use `dist/client` for Cloudflare adapter
55
+ builds), `--config` (default `unlighthouse.config.ts`), `--version` (pinned
56
+ Unlighthouse version). Start from
57
+ [`scripts/unlighthouse/unlighthouse.config.example.ts`](scripts/unlighthouse/unlighthouse.config.example.ts)
58
+ and add `.unlighthouse/` to the site's `.gitignore`.
59
+
60
+ ### Playwright preview config
61
+
62
+ Shared `playwright.config` for Astro sites that run e2e against
63
+ `astro preview`. It picks a free port outside CI, sets
64
+ `ASTRO_PREVIEW_BACKGROUND=false` (Astro backgrounds `preview` in agent
65
+ environments, which makes Playwright think the server exited), and drops the
66
+ `webServer` when `PLAYWRIGHT_BASE_URL` is set.
67
+
68
+ ```sh
69
+ bun add -d @taraxvoid/voidflow # or: pnpm add -D @taraxvoid/voidflow
70
+ ```
71
+
72
+ ```js
73
+ // playwright.config.js
74
+ import { definePreviewConfig } from '@taraxvoid/voidflow/playwright'
75
+
76
+ export default definePreviewConfig({ ciPort: 4141 })
77
+ ```
78
+
79
+ Options: `ciPort` (required in CI), `runner` (default `bun`, e.g. `pnpm`),
80
+ `timeout` (default 30000), `projects` (default Pixel 7 and Desktop Chrome),
81
+ `testDir` (default `./test/e2e`). Requires `@playwright/test` in the site.
82
+
83
+ ## Deployments
84
+
85
+ Example deployment job added to your workflow above. `resolve-env` maps the
86
+ branch to dev/staging/prod once, up front, so its output can be used both to
87
+ drive the deploy and to label the job (`Deploy [dev]`, `Deploy [staging]`,
88
+ `Deploy [prod]`) — a job's `name:` can reference `needs.<job>.outputs.*` but
89
+ not a step output from within itself, hence the split:
90
+
91
+ ```yaml
92
+ jobs:
93
+ resolve-env:
94
+ needs: validate
95
+ if: github.event_name == 'push'
96
+ uses: taraxvoid/voidflow/.github/workflows/resolve-env.yml@main
97
+
98
+ deploy:
99
+ name: Deploy [${{ needs.resolve-env.outputs.env }}]
100
+ needs: resolve-env
101
+ if: github.event_name == 'push'
102
+ environment: ${{ needs.resolve-env.outputs.env }}
103
+ steps:
104
+ - uses: actions/checkout@v7.0.1
105
+ - uses: taraxvoid/voidflow/actions/cloudflare-deploy@main
106
+ with:
107
+ env: ${{ needs.resolve-env.outputs.env }}
108
+ cloudflare-api-token: ${{ secrets.CLOUDFLARE_API_TOKEN }}
109
+ ```
110
+
111
+ ### Versioning
112
+
113
+ This project uses semver, released manually in two steps (`main` requires PRs, so nothing
114
+ can push straight to it — not even Actions):
115
+
116
+ 1. Dispatch [`release.yml`](.github/workflows/release.yml) (`workflow_dispatch`, with a
117
+ `version` input) — it regenerates [`CHANGELOG.md`](CHANGELOG.md) via git-cliff, validates,
118
+ and opens a `chore(release): prepare for vX.Y.Z` PR.
119
+ 2. Merging that PR triggers [`tag-release.yml`](.github/workflows/tag-release.yml), which
120
+ tags the merge commit and publishes the GitHub release. (Tag pushes aren't covered by
121
+ `main`'s branch protection, only branch pushes are.)
122
+
123
+ Commit messages should follow [Conventional Commits](https://www.conventionalcommits.org) —
124
+ enforced loosely, as an advisory `commit-msg` hint (see
125
+ [`scripts/check-commit-msg.sh`](scripts/check-commit-msg.sh)), not a blocking check. They
126
+ drive the generated changelog.
127
+
128
+ Treat `@main` as unstable. Consumers should pin to an exact release tag, e.g. `@v0.3.0` —
129
+ there is no floating major-version tag (`@v1`) to track yet.
130
+
131
+ ## Self-validation
132
+
133
+ This repo validates its own workflows and composite actions (`.github/workflows/ci.yml`):
134
+
135
+ - **actionlint** — workflow schema/logic; also shellchecks `run:` steps in `.github/workflows/*.yml` automatically (`shellcheck` ships on `ubuntu-latest`)
136
+ - **check-jsonschema** — schema-checks `actions/**/action.yml` and the workflow files against [SchemaStore](https://www.schemastore.org/)'s `github-action.json` / `github-workflow.json`
137
+ - **shellcheck** (`scripts/shellcheck-actions.sh`) — checks `run:` steps inside `actions/**/action.yml`, which actionlint doesn't reach
138
+ - **zizmor** — security audit (unpinned refs, script injection via `${{ }}` in `run:`, excess permissions, etc.)
139
+
140
+ ### Local dev setup
141
+
142
+ ```sh
143
+ brew install just lefthook actionlint shellcheck yq act uv
144
+ lefthook install
145
+ ```
146
+
147
+ - `just validate` — run everything CI runs, locally
148
+ - `just lint-workflows` / `lint-actions` / `lint-shell` / `security` — run one check at a time
149
+ - `just dry-run` — full local run of `ci.yml` via [`act`](https://github.com/nektos/act) (needs Docker running); pass extra args, e.g. `just dry-run -j validation`
150
+ - lefthook runs the fast checks (`lint-workflows`, `lint-actions`, `lint-shell`) on `pre-commit`, and `zizmor` on `pre-push`
151
+
152
+ **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.
153
+
154
+ **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 — `git ls-files`-based steps (schema validation, `scripts/shellcheck-actions.sh`) silently see zero files instead of erroring. Verified clean end-to-end (`🏁 Job succeeded`) from a plain clone.
155
+
156
+ YMMV, caveat emptor, your satisfaction **not** guaranteed
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "voidflow",
3
+ "version": "0.0.0",
4
+ "description": "Shared workflows, actions and tooling for @taraxvoid sites",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/taraxvoid/voidflow.git"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public",
12
+ "provenance": true
13
+ },
14
+ "bin": {
15
+ "unlighthouse-runner": "scripts/unlighthouse/run.ts"
16
+ },
17
+ "exports": {
18
+ "./playwright": {
19
+ "types": "./playwright/index.d.mts",
20
+ "default": "./playwright/index.mjs"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "files": [
25
+ "playwright",
26
+ "scripts/unlighthouse"
27
+ ],
28
+ "peerDependencies": {
29
+ "@playwright/test": ">=1.40"
30
+ }
31
+ }
@@ -0,0 +1,20 @@
1
+ import type { PlaywrightTestConfig, Project } from '@playwright/test'
2
+
3
+ export interface PreviewConfigOptions {
4
+ /** Fixed port used when `CI` is set. Required in CI. */
5
+ ciPort?: number
6
+ /** Package manager that runs the `preview` script. Default `bun`. */
7
+ runner?: 'bun' | 'pnpm' | 'npm' | 'yarn' | (string & {})
8
+ /** Test and web server timeout in ms. Default 30000. */
9
+ timeout?: number
10
+ /** Playwright projects. Default: mobile-chrome (Pixel 7) and desktop-chrome. */
11
+ projects?: Project[]
12
+ /** Default `./test/e2e`. */
13
+ testDir?: string
14
+ }
15
+
16
+ export const defaultProjects: Project[]
17
+
18
+ export function definePreviewConfig(
19
+ options?: PreviewConfigOptions,
20
+ ): PlaywrightTestConfig
@@ -0,0 +1,69 @@
1
+ /*
2
+ * Shared Playwright config for Astro sites that e2e against `astro preview`.
3
+ *
4
+ * import { definePreviewConfig } from '@taraxvoid/voidflow/playwright'
5
+ * export default definePreviewConfig({ ciPort: 4141 })
6
+ */
7
+ import { createServer } from 'node:net'
8
+ import { devices } from '@playwright/test'
9
+
10
+ function getFreePort() {
11
+ return new Promise((resolve, reject) => {
12
+ const srv = createServer()
13
+ srv.unref()
14
+ srv.on('error', reject)
15
+ srv.listen(0, () => {
16
+ const { port } = srv.address()
17
+ srv.close(() => resolve(port))
18
+ })
19
+ })
20
+ }
21
+
22
+ // Worktrees run tests concurrently, so a fixed port would collide (or reuse
23
+ // another worktree's server). Outside CI, ask the OS for a free port. The
24
+ // result is cached in PLAYWRIGHT_TEST_PORT because the config is reloaded in
25
+ // the runner and in every worker; each reload must agree on one port.
26
+ if (!process.env.CI && !process.env.PLAYWRIGHT_TEST_PORT) {
27
+ process.env.PLAYWRIGHT_TEST_PORT = String(await getFreePort())
28
+ }
29
+
30
+ export const defaultProjects = [
31
+ { name: 'mobile-chrome', use: devices['Pixel 7'] },
32
+ { name: 'desktop-chrome', use: devices['Desktop Chrome'] },
33
+ ]
34
+
35
+ export function definePreviewConfig({
36
+ ciPort,
37
+ runner = 'bun',
38
+ timeout = 30_000,
39
+ projects = defaultProjects,
40
+ testDir = './test/e2e',
41
+ } = {}) {
42
+ if (process.env.CI && ciPort === undefined) {
43
+ throw new Error('definePreviewConfig: ciPort is required in CI')
44
+ }
45
+ const port = process.env.CI ? ciPort : process.env.PLAYWRIGHT_TEST_PORT
46
+ const external = process.env.PLAYWRIGHT_BASE_URL
47
+ const baseURL = external ?? `http://localhost:${port}`
48
+
49
+ return {
50
+ testDir,
51
+ timeout,
52
+ fullyParallel: true,
53
+ use: { baseURL, serviceWorkers: 'block' },
54
+ // Set PLAYWRIGHT_BASE_URL to test a deployed site or preview instead.
55
+ webServer: external
56
+ ? undefined
57
+ : {
58
+ command: `${runner} run preview --port ${port}`,
59
+ url: baseURL,
60
+ reuseExistingServer: !process.env.CI,
61
+ timeout,
62
+ // Astro backgrounds `astro preview` in agentic environments
63
+ // (CLAUDECODE, AI_AGENT), which makes Playwright think the
64
+ // server exited early. Any non-empty value skips detection.
65
+ env: { ASTRO_PREVIEW_BACKGROUND: 'false' },
66
+ },
67
+ projects,
68
+ }
69
+ }
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env bun
2
+ /*
3
+ * Shared Unlighthouse CI runner for the Astro/bun site stack.
4
+ *
5
+ * Serves a built static directory on a free local port, then runs
6
+ * `unlighthouse-ci` against it with the caller's config file and exits with
7
+ * its status (non-zero when a category budget fails). Reuses the Chromium that
8
+ * Playwright already installed, so no second Chrome download.
9
+ *
10
+ * Usage:
11
+ * bun run.ts [--dir dist] [--config unlighthouse.config.ts] [--version 0.19.0]
12
+ *
13
+ * Paths are relative to the current working directory (the caller's repo root).
14
+ */
15
+ import { spawn } from 'node:child_process'
16
+ import { existsSync, readdirSync, statSync } from 'node:fs'
17
+ import { homedir } from 'node:os'
18
+ import { join, resolve } from 'node:path'
19
+ import { parseArgs } from 'node:util'
20
+
21
+ const { values } = parseArgs({
22
+ options: {
23
+ dir: { type: 'string', default: 'dist' },
24
+ config: { type: 'string', default: 'unlighthouse.config.ts' },
25
+ version: { type: 'string', default: '0.19.0' },
26
+ },
27
+ })
28
+
29
+ const dir = resolve(values.dir as string)
30
+ const config = resolve(values.config as string)
31
+
32
+ if (!existsSync(dir)) {
33
+ console.error(`dist directory not found: ${dir} (run the build first)`)
34
+ process.exit(2)
35
+ }
36
+ if (!existsSync(config)) {
37
+ console.error(`unlighthouse config not found: ${config}`)
38
+ process.exit(2)
39
+ }
40
+
41
+ function findPlaywrightChromium(): string | null {
42
+ const base =
43
+ process.platform === 'darwin'
44
+ ? join(homedir(), 'Library', 'Caches', 'ms-playwright')
45
+ : process.platform === 'win32'
46
+ ? join(homedir(), 'AppData', 'Local', 'ms-playwright')
47
+ : join(homedir(), '.cache', 'ms-playwright')
48
+ if (!existsSync(base)) return null
49
+
50
+ const relPaths = [
51
+ 'chrome-linux64/chrome',
52
+ 'chrome-linux/chrome',
53
+ 'chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing',
54
+ 'chrome-mac/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing',
55
+ 'chrome-win64/chrome.exe',
56
+ 'chrome-win/chrome.exe',
57
+ ]
58
+ for (const entry of readdirSync(base, { withFileTypes: true })) {
59
+ if (!entry.isDirectory()) continue
60
+ for (const rel of relPaths) {
61
+ const candidate = join(base, entry.name, rel)
62
+ if (existsSync(candidate)) return candidate
63
+ }
64
+ }
65
+ return null
66
+ }
67
+
68
+ // Resolve a request path to a file the way static hosts do: exact file,
69
+ // then <path>/index.html, then <path>.html.
70
+ function resolveFile(pathname: string): string | null {
71
+ const rel = decodeURIComponent(pathname).replace(/^\/+/, '')
72
+ const candidates = [rel, join(rel, 'index.html'), `${rel}.html`]
73
+ for (const c of candidates) {
74
+ const full = resolve(dir, c)
75
+ if (!full.startsWith(`${dir}/`) && full !== dir) continue
76
+ if (existsSync(full) && statSync(full).isFile()) return full
77
+ }
78
+ return null
79
+ }
80
+
81
+ const server = Bun.serve({
82
+ port: 0,
83
+ fetch(req) {
84
+ const file = resolveFile(new URL(req.url).pathname)
85
+ if (file) return new Response(Bun.file(file))
86
+ const notFound = resolveFile('/404.html')
87
+ return notFound
88
+ ? new Response(Bun.file(notFound), { status: 404 })
89
+ : new Response('Not found', { status: 404 })
90
+ },
91
+ })
92
+
93
+ const site = `http://localhost:${server.port}`
94
+ const chrome = findPlaywrightChromium()
95
+ const env: Record<string, string | undefined> = { ...process.env }
96
+ if (chrome) {
97
+ env.CHROME_PATH = chrome
98
+ env.PUPPETEER_EXECUTABLE_PATH = chrome
99
+ console.log(`Using Chromium: ${chrome}`)
100
+ } else {
101
+ console.log('No Playwright Chromium found; relying on system Chrome lookup.')
102
+ }
103
+ console.log(`Serving ${dir} at ${site}`)
104
+
105
+ const child = spawn(
106
+ 'bunx',
107
+ [
108
+ '--package',
109
+ `unlighthouse@${values.version}`,
110
+ 'unlighthouse-ci',
111
+ '--site',
112
+ site,
113
+ '--config-file',
114
+ config,
115
+ ],
116
+ { env, stdio: 'inherit' },
117
+ )
118
+
119
+ child.on('error', (err) => {
120
+ console.error('failed to start unlighthouse-ci:', err.message)
121
+ server.stop(true)
122
+ process.exit(1)
123
+ })
124
+ child.on('close', (code) => {
125
+ server.stop(true)
126
+ process.exit(code ?? 1)
127
+ })
@@ -0,0 +1,23 @@
1
+ // Example per-repo config, copy to the repo root as `unlighthouse.config.ts`.
2
+ // The runner supplies `site`, so it is not set here.
3
+ export default {
4
+ scanner: {
5
+ samples: 1,
6
+ device: 'mobile',
7
+ // Keep the run short: explicit routes, no crawling.
8
+ // urls: ['/', '/friends', '/friends/o4us'],
9
+ dynamicSampling: 3,
10
+ skipJavascript: true,
11
+ },
12
+ lighthouseOptions: {
13
+ onlyCategories: ['seo', 'performance'],
14
+ },
15
+ ci: {
16
+ // Per-category minimum scores (0-100); the run exits non-zero if any fail.
17
+ budget: {
18
+ seo: 80,
19
+ performance: 90,
20
+ },
21
+ buildStatic: false,
22
+ },
23
+ }