Appearance
CI/CD
Two GitHub Actions workflows:
.github/workflows/ci.yml— quality gates and tests. Runs on push to main and PRs to main..github/workflows/release.yml— the changesets version PR, GHCR image publishing, and GitHub Releases.
All jobs run on ubuntu-latest.
Caching strategy
This repo is orchestrated by Vite+ (the vp CLI), not Turbo or a standalone pnpm install step. CI caching has two layers:
- Dependencies — handled by
voidzero-dev/setup-vp@v1withcache: true. The action provisions Node (pinned vianode-version-file: .node-version) plus pnpm, restores the dependency cache, and runsvp install. There is no separateactions/cachestep for the pnpm store. - Vite+ task cache — each of
check,test,build, anddeploy-docsadds oneactions/cache@v4step for the pathnode_modules/.vite/task-cache, keyed per job, OS, and commit SHA, withrestore-keysfalling back to the base branch (ormain) and then any prior run for that job and OS. This replays per-package task outputs across runs when inputs are unchanged — the rough equivalent of a remote build cache.
The key scheme follows Vite+'s GitHub Actions cache guide:
yaml
key: vp-task-<job>-${{ runner.os }}-${{ runner.arch }}-${{ github.run_id }}-${{ github.run_attempt }}
restore-keys: |
vp-task-<job>-${{ runner.os }}-${{ runner.arch }}-Why it is shaped this way:
runner.os+runner.arch— task outputs and native tools are platform-specific.github.run_id+github.run_attempt— Actions cache entries are immutable, so the primary key must be unique per attempt. A per-commit key (github.sha) means re-running the same commit can never save a new entry.- Task inputs are deliberately absent from the key. Sources and the lockfile are fingerprinted by Vite Task itself; putting them in the Actions key makes GitHub skip otherwise-useful restores before Vite Task gets to decide which tasks still hit.
- The per-job prefix is a local addition. Every job in a run shares one
run_id, so a single shared key would have the jobs collide when saving.
Restore must happen after setup-vp, because installing dependencies rewrites node_modules.
Experimental, and worth measuring
Upstream flags cross-run reuse of the Vite Task cache as experimental. Restore and save add their own overhead, so for fast tasks it can cost more than it saves — measure before assuming it helps.
GitHub also caps each repository at 10 GB of Actions cache with LRU eviction, and the per-attempt primary key means every run writes a new entry. The restore-keys prefix still finds the newest compatible entry, but if hit rates look poor this is the first thing to examine.
actions/checkout@v4 uses fetch-depth: 0. Jobs run their task recursively across all packages with vp run --cache -r <task> (the check job adds -v). Skipping unchanged work comes from the task-cache replay, not from an affected-graph filter — there is no --affected flag in the workflow.
Concurrency
ci.yml sets concurrency: ci-<workflow>-<ref> with cancel-in-progress: true, so a force-push or a rapid follow-up commit cancels the superseded run instead of leaving an e2e matrix burning runners.
Shared build artifact
The build job runs once, produces apps/web/dist/ and services/api-hono/dist/, and tars up the fully-materialized node_modules/ with all its .pnpm/ virtual store links. It publishes the tarball as build-output-<sha> via actions/upload-artifact@v4. The e2e shards download it via actions/download-artifact@v4 and extract it, so they skip both install and build — they run setup-vp with run-install: false.
E2E sharding
e2e is matrix-sharded (shard: ["1/3", "2/3", "3/3"]) so the Playwright suite runs in parallel across three shards. fail-fast: false — a failure in one shard does not cancel the others.
Jobs (ci.yml)
1. check
Code quality gate — runs on every trigger.
- Set up Vite+ via
voidzero-dev/setup-vp@v1withnode-version-file: .node-versionandcache: true(provisions Node + pnpm, restores deps, runsvp install). - Restore the Vite+ task cache (
actions/cache@v4, pathnode_modules/.vite/task-cache). - Run
vp run --cache -r -v check— format + lint + typecheck across all packages. The docs workspace runs only fmt + lint here; the full VitePress build that catches missing pages, invalid frontmatter, and bad links runs in thebuildjob and indeploy-docs, not incheck. Vite Task replays cached steps for unchanged packages.
2. test
Unit tests — runs on every trigger.
- Set up Vite+ (
setup-vp@v1,cache: true). - Restore the Vite+ task cache (
actions/cache@v4). - Run
vp run --cache -r test— Vite Task fingerprints inputs per package, so unchanged packages replay from cache.
3. build
Shared prerequisite for the e2e job — runs once after check passes. Builds every workspace project with vp run --cache -r build (api + web + docs) and publishes a build-output-<sha> artifact containing:
- Root
node_modules/(fully materialized with pnpm's.pnpm/virtual store) apps/web/node_modules/andapps/web/dist/services/api-hono/node_modules/andservices/api-hono/dist/tests/e2e/node_modules/(Playwright workspace)docs/node_modules/(the tar command has a fallback that omitsdocs/node_modulesif it is absent)
Retention is 1 day (intermediate build artifact, not a release asset).
4. e2e
Playwright end-to-end tests — runs on both PRs and pushes to main, depends on build.
Container: mcr.microsoft.com/playwright:v1.62.1-jammy — Microsoft's official Playwright image with browsers + system deps pre-installed. The Playwright version is pinned in pnpm-workspace.yaml under the catalog (@playwright/test: 1.62.1); tests/e2e/package.json references it as catalog:. When upgrading Playwright, bump the workspace catalog and the ci.yml container tag in lockstep. (docker-compose.test.yml uses the -noble variant, v1.62.1-noble, for the local Playwright service.)
Nothing enforces that lockstep. The image ships only the browser build its own version pins, so a catalog bump on its own leaves every E2E job failing at browser launch with Executable doesn't exist at /ms-playwright/... — the tests never start, so the failure looks nothing like a test regression.
Services: PostgreSQL 17 + Redis 7 (inline service containers, each with a health check — pg_isready / redis-cli ping — with a 2s interval).
Scope: one job handles both triggers so the service, container, and env setup cannot drift between them:
- On
pull_request— the@smokesubset, for fast feedback. - On
pushto main — the full suite, no tag filter.
@smoke is therefore the PR gate's whole definition of "covered". Everything that pins an authentication or authorization invariant carries the tag — see testing.md § Tags for what that includes and why. When you add a security-relevant spec, tag it, or it first runs post-merge.
Artifacts:
- Playwright HTML report —
e2e-report-<idx>(always uploaded, 7-day retention). - Test results (screenshots, videos, traces) —
e2e-test-results-<idx>(uploaded on failure, 7-day retention). - API server log —
e2e-api-log-<idx>(uploaded on failure, 7-day retention). The trace shows the 401; this shows why the API sent it.
<idx> is strategy.job-index. Artifacts use actions/upload-artifact@v4, whose names must be unique within a run — hence the per-shard index.
Pass/fail is surfaced by GitHub's native checks UI on the PR; there is no bot comment.
5. e2e-prod-config
The production configuration branch — runs on both PRs and pushes to main, depends on build, one runner, no sharding.
Why it is a separate job. The e2e job structurally cannot reach production config: bootstrap.ts throws E2E_TEST=1 is not allowed in NODE_ENV=production, and without E2E_TEST the /__test/* support routes the suite depends on are never mounted. So every ordinary shard exercises the development side of every NODE_ENV branch — trustedOrigins in lib/auth.ts, the Redis-vs-memory store split in bootstrap.ts, and the logger transport. A 150-test green suite consequently missed "production HTTPS deployments cannot sign in" entirely.
What it runs. vp exec playwright test --project=prod-config, with E2E_PROD_CONFIG=1. That variable reshapes tests/e2e/playwright.config.ts into a one-project run over prod-config.spec.ts, and short-circuits global-setup.ts after the database reset — there are no support routes and no seeded accounts, so the spec drives first-run setup, sign-out, sign-in and cookie replay through the browser.
The configuration under test: NODE_ENV=production, no E2E_TEST, no TEST_ROUTE_TOKEN, and BETTER_AUTH_URL set to the origin the browser drives. That last one is the crux: trustedOrigins is [] in production and Better Auth resolves its trusted origin from baseURL once, at startup, so an absent or wrong BETTER_AUTH_URL does not fail to boot — it turns every cookie-bearing POST into a 403 INVALID_ORIGIN. env.ts requires the variable in production for that reason; this job is what proves the required value is sufficient to sign in, sign out and revoke with. LIBRIS_COOKIE_SECURE=0 is the single concession to running over plain HTTP; Chromium would otherwise discard the session cookie.
It gets its own libris_prod_test database and its own Redis so it cannot disturb the sharded suite.
Artifacts: e2e-prod-config-report (always), e2e-prod-config-test-results and e2e-prod-config-api-log (on failure).
6. deploy-docs
Deploys VitePress documentation to Cloudflare Pages — runs on push to main only, depends on check passing.
- Gate step:
git diff --name-only HEAD^ HEAD— skip the rest of the job when the push doesn't touchdocs/,pnpm-lock.yaml,vite.config.ts, or any rootpackage.json. - Set up Vite+ (
setup-vp@v1,cache: true) and restore the Vite+ task cache (actions/cache@v4). - Build docs (
vp run --cache -F @libris/docs build, which also runs@libris/api-hono#build:specfor the OpenAPI spec via dependsOn) — replayed from Vite Task cache when unchanged. - Deploy
docs/.vitepress/distviavp exec wrangler pages deploy.
Secrets required: CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_TOKEN
Environment Variables (CI)
Set on the e2e job:
CI=true
NODE_ENV=development
E2E_TEST=1
LIBRIS_COOKIE_SECURE=0
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_USER=libris_test
POSTGRES_PASSWORD=libris_test
POSTGRES_DB=libris_test
REDIS_HOST=redis
REDIS_PORT=6379
LIBRIS_INBOX_PATH=/tmp/e2e-inbox
LIBRIS_LIBRARY_PATH=/tmp/e2e-library
API_SECRET_KEY=<openssl rand -hex 32>
BETTER_AUTH_SECRET=<openssl rand -hex 32>
TEST_ROUTE_TOKEN=<openssl rand -hex 32>
MIGRATIONS_PATH=./services/api-hono/migrations
LIBRIS_API_LOG=/tmp/e2e-api.logFour of those are easy to get wrong and each one is fatal rather than flaky:
NODE_ENVhas no default inservices/api-hono/src/env.ts. Omit it andgetEnv()throws aZodErrorbefore the server binds a port, so Playwright'swebServertimes out after 60s and every shard fails without running a test. The value must matchdocker-compose.test.yml(development) so the two harnesses exercise the same branches. It must not beproduction:bootstrap.tsthrowsE2E_TEST=1 is not allowed in NODE_ENV=production. The production config is covered by its own job,e2e-prod-config.TEST_ROUTE_TOKENauthenticates the/__test/*support routes that seed books, clear caches and emit events.tests/e2e/helpers/index.tsthrows outright when it is unset, andsrc/middleware/auth.tsrejects any token shorter than 32 bytes — a short value silently 401s every support-route call rather than reporting a config error.API_SECRET_KEY/BETTER_AUTH_SECRETare validated at startup: published placeholders and low-diversity strings are rejected. Generate CI's throwaway values withopenssl rand -hex 32like any other, so a future tightening of the validator does not take CI down with it.LIBRIS_API_LOGis Libris-specific, not a framework variable. When it is set,tests/e2e/playwright.config.tstees the API process's stdout/stderr to that path so a failing shard can upload it (e2e-api-log-<idx>).
services/api-hono/src/workflow-env.test.ts parses these blocks straight out of ci.yml and runs them through the real parseEnv(), so a variable that goes missing fails in vp run test rather than in a 60-second webServer timeout.
Set on the e2e-prod-config job — the same shape, inverted where it matters:
CI=true
E2E_PROD_CONFIG=1
NODE_ENV=production
LIBRIS_COOKIE_SECURE=0
POSTGRES_DB=libris_prod_test # its own database
LIBRIS_INBOX_PATH=/tmp/e2e-prod-inbox
LIBRIS_LIBRARY_PATH=/tmp/e2e-prod-library
API_SECRET_KEY=<openssl rand -hex 32>
BETTER_AUTH_SECRET=<openssl rand -hex 32>
MIGRATIONS_PATH=./services/api-hono/migrations
LIBRIS_API_LOG=/tmp/e2e-prod-api.logThree variables are absent on purpose and the job is pointless without that: E2E_TEST (would be refused by bootstrap.ts, and would mount support routes a production build must not have), TEST_ROUTE_TOKEN (same), and BETTER_AUTH_URL — present here and nowhere else, because env.ts requires it under NODE_ENV=production and the whole point of the job is to check the required value actually works.
Release (release.yml)
Releases are driven by changesets. The flow is PR-gated rather than manually dispatched.
How a release happens
- You add a changeset with your PR —
pnpm changeset, or hand-write.changeset/<name>.md. Every code change needs one, or nothing will ever be released. - Your PR merges to main. The
versionjob runschangesets/action@v1, which consumes every.changeset/*.md, bumps versions, rewrites the CHANGELOGs, and opens (or updates) a "chore: version packages" pull request. - You review and merge that version PR. This is the actual release gate.
- The merge lands the bumped versions on main, and the
publishjob builds the image and cuts the releases.
version job
Runs on push to main. Uses changesets/action@v1 without a publish input — that input drives npm publishing, and nothing here goes to npm (every workspace is private; .changeset/config.json sets access: restricted). The action is used purely to maintain the version PR.
.changeset/config.json puts @libris/web, @libris/api-hono, and @libris/docs in a fixed group, so they always bump together. @libris/e2e is ignored.
Permissions: contents: write, pull-requests: write.
CI on the version PR needs manual approval
The version PR is opened by github-actions[bot]. GitHub creates the ci.yml run for it but holds it in action_required with zero jobs executed, pending approval — the same gate it applies to workflow runs on pull requests from bots and first-time contributors.
Approve it with the "Approve and run" button on the PR's checks tab, or from the CLI:
bash
gh api -X POST repos/RazorSiM/libris/actions/runs/<run-id>/approveOnce approved the run executes and reports normally, so required status checks on main do not permanently deadlock the version PR — but every release costs one approval before the PR becomes mergeable.
The image build is unaffected either way: merging produces an ordinary push event, and release.yml runs ungated.
publish job
Runs on every push to main, but is a no-op unless the version actually changed. It reads the current versions out of services/api-hono/package.json and apps/web/package.json, derives the composite tag v<api-version>-web<web-version>, and runs docker manifest inspect against GHCR. If that tag already exists, the job stops there. This is why no commit-message sniffing is needed to detect "the version PR just merged" — a new version simply produces a tag that isn't in the registry yet.
When a build is needed it uses docker/setup-buildx-action@v3 + docker/build-push-action@v6 with cache-from/cache-to: type=gha, pushing both :<tag> and :latest.
Only when an image was actually published does it create the git tags (api-hono/v<version>, web/v<version>) and GitHub Releases via gh release create, with each release body extracted from that package's CHANGELOG.md. Existing releases are skipped, so re-runs are idempotent.
Registry auth: docker/login-action@v3 against ghcr.io using the built-in GITHUB_TOKEN with packages: write. There is no separate registry token to manage.
Image produced: ghcr.io/RazorSiM/libris:v<api-version>-web<web-version> and :latest.
Manual override: workflow_dispatch accepts a force_publish boolean that rebuilds and pushes even when the tag already exists.
First publish
The first push to GHCR creates the package as private, regardless of repository visibility. It must be made public separately, or production pulls will fail with an auth error.
See docs/deployment.md for production usage.
Docker Test Services
docker-compose.test.yml for local testing:
| Service | Port | Config |
|---|---|---|
| PostgreSQL 17 | 5433 | DB: libris_test, tmpfs storage |
| Redis 7 | 6380 | Default storage |
PostgreSQL uses tmpfs for fast ephemeral storage. Redis uses its default in-memory store.