diff --git a/.github/workflows/resolve-env.yml b/.github/workflows/resolve-env.yml index 7d20fc3..49af8e6 100644 --- a/.github/workflows/resolve-env.yml +++ b/.github/workflows/resolve-env.yml @@ -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. @@ -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 diff --git a/.release-please/main-manifest.json b/.release-please/main-manifest.json index ddfa3e3..8032c17 100644 --- a/.release-please/main-manifest.json +++ b/.release-please/main-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.11.1" + ".": "0.12.0" } diff --git a/.release-please/next-manifest.json b/.release-please/next-manifest.json index ddfa3e3..8032c17 100644 --- a/.release-please/next-manifest.json +++ b/.release-please/next-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.11.1" + ".": "0.12.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 76b724b..f48bbfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index a18df2b..b947dde 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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@ check-licenses" +"check:licenses": "check-licenses" ``` +Don't use `bunx --package @taraxvoid/voidflow@ ...` 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 @@ -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 (` run --if-present`, so a missing script is skipped; `` 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 diff --git a/jsr.json b/jsr.json index 085437a..336260e 100644 --- a/jsr.json +++ b/jsr.json @@ -1,6 +1,6 @@ { "name": "@taraxvoid/voidflow", - "version": "0.11.1", + "version": "0.12.0", "license": "MIT", "exports": { "./playwright": "./playwright/index.mjs" diff --git a/lefthook/site.yml b/lefthook/site.yml index b1a9c94..47dc8e6 100644 --- a/lefthook/site.yml +++ b/lefthook/site.yml @@ -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 +# (` run --if-present`). # # # lefthook.yml in the site # remotes: # - git_url: https://github.com/taraxvoid/voidflow -# ref: # pin; bump deliberately +# ref: # a tag or branch (not a SHA); bump deliberately # configs: # - lefthook/site.yml # @@ -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: @@ -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: @@ -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 diff --git a/package.json b/package.json index be4a64d..d9c4048 100644 --- a/package.json +++ b/package.json @@ -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": [ diff --git a/test/branch-env-map.test.ts b/test/branch-env-map.test.ts new file mode 100644 index 0000000..6f0fa7a --- /dev/null +++ b/test/branch-env-map.test.ts @@ -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) + }) +}) diff --git a/test/lefthook-site.test.ts b/test/lefthook-site.test.ts index 574c31d..f44c796 100644 --- a/test/lefthook-site.test.ts +++ b/test/lefthook-site.test.ts @@ -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') }) })