Skip to content
Merged
37 changes: 29 additions & 8 deletions .github/workflows/resolve-env.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
name: Resolve env

# Resolves the deploy environment (dev/staging/prod) for the current branch
# via the branch-env-map action, exposing it as a job output so callers can
# reference it in a downstream job's `name:` (e.g. `Deploy [${{ needs.resolve-env.outputs.env }}]`).
# using the same mapping as the branch-env-map action, exposing it as a job
# output so callers can reference it in a downstream job's `name:` (e.g.
# `Deploy [${{ needs.resolve-env.outputs.env }}]`).
# Job names may use the `needs` context, but not composite action outputs
# directly, so this indirection is what makes that possible.

Expand Down Expand Up @@ -34,10 +35,30 @@ jobs:
outputs:
env: ${{ steps.map.outputs.env }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# Same mapping as actions/branch-env-map (a test keeps them identical).
# Inlined because a reusable workflow can't reach `./actions/...` in its
# own repo: checkout gives it the caller's tree, not voidflow's.
- id: map
uses: ./actions/branch-env-map
with:
single-environment: ${{ inputs.single-environment }}
shell: bash
env:
REF_NAME: ${{ github.ref_name }}
EVENT_NAME: ${{ github.event_name }}
SINGLE: ${{ inputs.single-environment }}
run: |
if [[ "$SINGLE" == "true" ]]; then
if [[ "$EVENT_NAME" == pull_request* ]]; then
echo "env=preview" >> "$GITHUB_OUTPUT"
elif [[ "$REF_NAME" == main ]]; then
echo "env=prod" >> "$GITHUB_OUTPUT"
else
echo "::error::Refusing to deploy unknown ref: $REF_NAME (expected main or a pull request)" >&2
exit 1
fi
exit 0
fi
case "$REF_NAME" in
main) echo "env=dev" >> "$GITHUB_OUTPUT" ;;
next) echo "env=staging" >> "$GITHUB_OUTPUT" ;;
live) echo "env=prod" >> "$GITHUB_OUTPUT" ;;
*) echo "::error::Refusing to deploy unknown branch: $REF_NAME (expected main, next, or live)" >&2; exit 1 ;;
esac
2 changes: 1 addition & 1 deletion .release-please/main-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "0.11.1"
".": "0.12.0"
}
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.11.1"
".": "0.12.0"
}
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ All notable changes to this project are documented here, generated by
[release-please](https://github.com/googleapis/release-please) from
[Conventional Commits](https://www.conventionalcommits.org).

## [0.12.0](https://github.com/taraxvoid/voidflow/compare/v0.11.1...v0.12.0) (2026-10-06)


### Features

* **lefthook:** pick bun or pnpm from the lockfile in the shared site hooks ([#119](https://github.com/taraxvoid/voidflow/issues/119)) ([fc33ffd](https://github.com/taraxvoid/voidflow/commit/fc33ffd2c3314ed8e4a3be6a2d006789b832d1fd))


### Bug Fixes

* **resolve-env:** inline the env mapping so the reusable workflow works cross-repo ([#118](https://github.com/taraxvoid/voidflow/issues/118)) ([cc14d77](https://github.com/taraxvoid/voidflow/commit/cc14d77befdb4532e8ba448d37b0f4087ca99e1d))

## [0.11.1](https://github.com/taraxvoid/voidflow/compare/v0.11.0...v0.11.1) (2026-10-02)


Expand Down
21 changes: 15 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,11 @@ static directory on a free port and runs
[Unlighthouse](https://unlighthouse.dev/) against it, reusing Playwright's
Chromium. It exits non-zero if a category budget in the site's config fails.

In the site's `package.json` (pin the tag):
Add `@taraxvoid/voidflow` as a devDependency and call the bin from the site's
`package.json`:

```json
"test:e2e:lighthouse": "bun run build && bunx --package @taraxvoid/voidflow@0.8.1 unlighthouse-runner --dir dist"
"test:e2e:lighthouse": "bun run build && unlighthouse-runner --dir dist"
```

Options: `--dir` (default `dist`, use `dist/client` for Cloudflare adapter
Expand All @@ -126,12 +127,20 @@ change. Sites implement that script with the shared check, which runs
`license-checker` in the site's repo root and fails on GPL, AGPL, SSPL and
similar copyleft licenses (LGPL is allowed).

In the site's `package.json` (pin the tag):
With `@taraxvoid/voidflow` as a devDependency, call the bin from the site's
`package.json`:

```json
"check:licenses": "bunx --package @taraxvoid/voidflow@<version> check-licenses"
"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`.

### Playwright preview config

Shared `playwright.config` for Astro sites that run e2e against
Expand Down Expand Up @@ -182,12 +191,12 @@ 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.

||||||| 4041a59
## Git hooks (lefthook)

[`lefthook/site.yml`](lefthook/site.yml) is the shared hook set for the site
stack, consumed as a lefthook remote. It calls the site's own `package.json`
scripts (`bun run --if-present`, so a missing script is skipped):
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, `format` (formatted files are re-staged),
`lint:actions`, `check`, `test:unit`, and `check:licenses` / `audit` when the
Expand Down
2 changes: 1 addition & 1 deletion jsr.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@taraxvoid/voidflow",
"version": "0.11.1",
"version": "0.12.0",
"license": "MIT",
"exports": {
"./playwright": "./playwright/index.mjs"
Expand Down
39 changes: 25 additions & 14 deletions lefthook/site.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
# Shared git hooks for the Astro/bun site stack. Sites pull this in as a
# lefthook remote and call their own package.json scripts, so a script a site
# does not define is skipped (`bun run --if-present`).
# Shared git hooks for the Astro site stack (bun, or pnpm when the repo has a
# pnpm-lock.yaml). Sites pull this in as a lefthook remote and call their own
# package.json scripts, so a script a site does not define is skipped
# (`<pm> run --if-present`).
#
# # lefthook.yml in the site
# remotes:
# - git_url: https://github.com/taraxvoid/voidflow
# ref: <commit sha> # pin; bump deliberately
# ref: <release tag> # a tag or branch (not a SHA); bump deliberately
# configs:
# - lefthook/site.yml
#
Expand All @@ -14,6 +15,10 @@
# one defined here. Differences go through package.json scripts instead, e.g.
# `test:unit:precommit` replaces `test:unit` for the pre-commit unit step.

templates:
# Package manager: pnpm when the repo is a pnpm project, bun otherwise.
pm: "$(if [ -f pnpm-lock.yaml ]; then echo pnpm; else echo bun; fi)"

pre-commit:
skip: [merge, rebase]
commands:
Expand All @@ -22,39 +27,45 @@ pre-commit:
run: |
if [ ! -d node_modules ]; then
echo "pre-commit: 'node_modules' is missing, dependencies aren't installed."
echo " Run 'bun install' before committing, then commit again."
echo " Run '{pm} install' before committing, then commit again."
exit 1
fi
format:
priority: 2
run: bun run --if-present format
run: "{pm} run --if-present format"
stage_fixed: true
lint-actions:
priority: 3
run: bun run --if-present lint:actions
run: "{pm} run --if-present lint:actions"
typecheck:
priority: 3
run: bun run --if-present check
run: "{pm} run --if-present check"
unit:
priority: 3
# A site that can't run its whole suite pre-commit (e.g. tests that need
# a built dist/) defines `test:unit:precommit`; otherwise test:unit runs.
run: |
if grep -q '"test:unit:precommit"' package.json; then
bun run test:unit:precommit
{pm} run test:unit:precommit
else
bun run --if-present test:unit
{pm} run --if-present test:unit
fi
licenses:
priority: 3
glob: "{package.json,bun.lock,pnpm-lock.yaml}"
run: bun run --if-present check:licenses
run: "{pm} run --if-present check:licenses"
audit:
# Local only: hits a remote vulnerability database and can hang offline,
# so it runs when dependencies changed and fails closed.
priority: 3
glob: "{package.json,bun.lock,pnpm-lock.yaml}"
run: bun run --if-present audit
# Checked by hand: under bun, `--if-present` falls through to
# the system `audit` binary (macOS /usr/sbin/audit) when the site has no
# audit script, and that exits non-zero.
run: |
if grep -q '"audit"' package.json; then
{pm} run audit
fi

commit-msg:
commands:
Expand Down Expand Up @@ -109,8 +120,8 @@ pre-push:

if [ ! -d node_modules ]; then
echo "pre-push: 'node_modules' is missing, dependencies aren't installed."
echo " Run 'bun install' before pushing, then push again."
echo " Run '{pm} install' before pushing, then push again."
exit 1
fi

bun run --if-present test:push
{pm} run --if-present test:push
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@taraxvoid/voidflow",
"version": "0.11.1",
"version": "0.12.0",
"description": "Shared workflows, actions and tooling for @taraxvoid sites",
"license": "MIT",
"keywords": [
Expand Down
71 changes: 71 additions & 0 deletions test/branch-env-map.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, test } from 'bun:test'
import { mkdtempSync, readFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

const read = (p: string) => readFileSync(new URL(`../${p}`, import.meta.url), 'utf8')

const actionScript: string = Bun.YAML.parse(read('actions/branch-env-map/action.yml')).runs.steps.find(
(s: any) => s.id === 'map',
).run
const workflowScript: string = Bun.YAML.parse(read('.github/workflows/resolve-env.yml')).jobs[
'resolve-env'
].steps.find((s: any) => s.id === 'map').run

async function map(script: string, ref: string, event: string, single: boolean) {
const out = join(mkdtempSync(join(tmpdir(), 'voidflow-env-')), 'out')
await Bun.write(out, '')
const proc = Bun.spawn(['bash', '-c', script], {
env: {
PATH: process.env.PATH,
REF_NAME: ref,
EVENT_NAME: event,
SINGLE: String(single),
GITHUB_OUTPUT: out,
},
stdout: 'pipe',
stderr: 'pipe',
})
const code = await proc.exited
return { code, env: readFileSync(out, 'utf8').trim() }
}

describe('resolve-env.yml carries the same mapping as branch-env-map', () => {
test('scripts are identical (no drift)', () => {
expect(workflowScript).toBe(actionScript)
})

test('resolve-env.yml has no `uses` steps (a local ./actions path or checkout cannot work cross-repo)', () => {
const steps = Bun.YAML.parse(read('.github/workflows/resolve-env.yml')).jobs['resolve-env'].steps
expect(steps.filter((s: any) => s.uses)).toEqual([])
})
})

describe.each([
['action', actionScript],
['workflow', workflowScript],
])('%s script', (_name, script) => {
test.each([
['main', 'push', 'env=dev'],
['next', 'push', 'env=staging'],
['live', 'push', 'env=prod'],
])('default mode: %s on %s', async (ref, event, expected) => {
expect(await map(script, ref, event, false)).toEqual({ code: 0, env: expected })
})

test('default mode rejects unknown branches', async () => {
expect((await map(script, 'feature/x', 'push', false)).code).toBe(1)
})

test.each([
['main', 'push', 'env=prod'],
['123/merge', 'pull_request', 'env=preview'],
['feature/x', 'pull_request', 'env=preview'],
])('single-environment: %s on %s', async (ref, event, expected) => {
expect(await map(script, ref, event, true)).toEqual({ code: 0, env: expected })
})

test('single-environment rejects non-main pushes', async () => {
expect((await map(script, 'feature/x', 'push', true)).code).toBe(1)
})
})
16 changes: 14 additions & 2 deletions test/lefthook-site.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,19 @@ describe('lefthook/site.yml', () => {
test('pre-commit unit step prefers test:unit:precommit when a site defines it', async () => {
const text = await Bun.file(config).text()
expect(text).toContain('grep -q \'"test:unit:precommit"\' package.json')
expect(text).toContain('bun run test:unit:precommit')
expect(text).toContain('bun run --if-present test:unit')
expect(text).toContain('{pm} run test:unit:precommit')
expect(text).toContain('{pm} run --if-present test:unit')
})

test('picks the package manager from the lockfile instead of hardcoding bun', async () => {
const text = await Bun.file(config).text()
expect(text).toContain('pnpm-lock.yaml ]; then echo pnpm; else echo bun')
expect(text).not.toMatch(/\bbun run\b/)
})

test('audit step only runs when the site defines an audit script', async () => {
const text = await Bun.file(config).text()
expect(text).toContain('grep -q \'"audit"\' package.json')
expect(text).not.toContain('--if-present audit')
})
})
Loading