Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 4 additions & 39 deletions .github/workflows/typescript.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,43 +51,8 @@ jobs:
# its <Editor> components assert at build time that every port in
# languages.json has example files, so this is where a forgotten port's
# missing examples become a red build instead of surfacing only at deploy.
#
# Deployment (and the versioned archive under /v/<version>/) lives in
# website.yml — it needs the release tags this workflow's path filter
# doesn't react to.
- run: pnpm --filter @varar/website... build

deploy-website:
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
concurrency:
group: deploy-website
cancel-in-progress: false
defaults:
run:
working-directory: typescript
steps:
- uses: actions/checkout@v7

# No version here — pnpm/action-setup reads "packageManager" from
# typescript/package.json, the same field corepack uses locally.
- uses: pnpm/action-setup@v6
with:
package_json_file: typescript/package.json

# No version here — .node-version is the pin, read by setup-node and by
# nvm/fnm/asdf alike, so CI and a developer machine cannot drift.
- uses: actions/setup-node@v7
with:
node-version-file: typescript/.node-version
cache: pnpm
cache-dependency-path: typescript/pnpm-lock.yaml

- run: pnpm install --frozen-lockfile

- run: pnpm --filter @varar/website... build

- uses: cloudflare/wrangler-action@v4
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: typescript/packages/website
wranglerVersion: "4"
packageManager: pnpm
193 changes: 193 additions & 0 deletions .github/workflows/website.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
name: Website

# Builds and deploys https://varar.dev, and maintains the version archive served
# under https://varar.dev/v/<version>/ — a build-accurate copy of the website as
# it shipped for each past release (interactive editor and all).
#
# Flow:
# - push to main → deploy (live site + existing archives overlaid)
# - push tag v* → snapshot that release, commit it to the orphan
# `website-snapshots` branch, then deploy
# - workflow_dispatch → backfill: snapshot one version (input) or every
# release tag still missing an archive, then deploy
#
# `deploy` chains after `snapshot` via `needs`, so a freshly-archived release
# ships in the same run — no reliance on a bot push re-triggering CI (it doesn't).
#
# The website PR build gate lives in typescript.yml's `test` job and stays there.

on:
push:
branches: [main]
tags: ['v*']
workflow_dispatch:
inputs:
version:
description: 'Backfill: bare semver to (re)snapshot, e.g. 0.6.1. Blank = every release tag missing an archive.'
required: false
default: ''

jobs:
snapshot:
# Only tag pushes and manual backfills produce archives; a plain main push
# skips straight to deploy.
if: startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
permissions:
contents: write
concurrency:
group: website-snapshots
cancel-in-progress: false
steps:
# Full history + tags: we build each release from a worktree of its tag,
# using the snapshot tooling from this (current) checkout.
- uses: actions/checkout@v7
with:
fetch-depth: 0

- uses: pnpm/action-setup@v6
with:
package_json_file: typescript/package.json

- uses: actions/setup-node@v7
with:
node-version-file: typescript/.node-version
cache: pnpm
cache-dependency-path: typescript/pnpm-lock.yaml

# The archive lives on an orphan `website-snapshots` branch — built assets
# for every past release, kept off main's history. Check it out beside the
# source; start it empty the first time.
- uses: actions/checkout@v7
id: archive
continue-on-error: true
with:
ref: website-snapshots
path: snapshots
fetch-depth: 0

- name: Initialise archive branch if absent
if: steps.archive.outcome != 'success'
run: |
rm -rf snapshots && mkdir snapshots
cd snapshots
git init -q -b website-snapshots

- name: Compute versions to snapshot
id: versions
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = workflow_dispatch ]; then
if [ -n "${{ inputs.version }}" ]; then
list="${{ inputs.version }}"
else
list=""
for t in $(git tag -l 'v*' | sed 's/^v//' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$'); do
[ -d "snapshots/v/$t" ] || list="$list $t"
done
fi
else
list="${GITHUB_REF_NAME#v}" # tag push
fi
echo "list=$(echo "$list" | xargs)" >> "$GITHUB_OUTPUT"

- name: Build snapshots
if: steps.versions.outputs.list != ''
run: |
set -euo pipefail
mkdir -p snapshots/v
for v in ${{ steps.versions.outputs.list }}; do
echo "::group::snapshot v$v"
wt="$RUNNER_TEMP/src-$v"
rm -rf "$wt"
git worktree add --force "$wt" "v$v"
(cd "$wt/typescript" && pnpm install --frozen-lockfile)
release/website-snapshot.sh "$v" "$PWD/snapshots/v/$v" "$wt"
git worktree remove --force "$wt"
echo "::endgroup::"
done

- name: Regenerate archive index
run: node release/website-index.mjs snapshots/v

- name: Commit and push archive
run: |
set -euo pipefail
cd snapshots
git add -A
if git diff --cached --quiet; then
echo "archive unchanged"
exit 0
fi
git -c user.name='github-actions[bot]' \
-c user.email='41898282+github-actions[bot]@users.noreply.github.com' \
commit -m "chore(website): archive snapshot(s) ${{ steps.versions.outputs.list }}"
# Explicit token auth so the push works both when this branch was
# checked out (auth in a sibling dir) and when we just init'd it. A
# GITHUB_TOKEN push doesn't re-trigger CI, which is fine: `deploy`
# chains off this job via `needs`, it isn't waiting on a push event.
git remote remove origin 2>/dev/null || true
git remote add origin \
"https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.git"
git push -u origin website-snapshots

deploy:
needs: [snapshot]
# Runs after snapshot on tag/dispatch, and on its own for a plain main push
# (where snapshot is skipped). Never on PRs.
if: |
always() &&
github.event_name != 'pull_request' &&
(needs.snapshot.result == 'success' || needs.snapshot.result == 'skipped')
runs-on: ubuntu-latest
concurrency:
group: deploy-website
cancel-in-progress: false
defaults:
run:
working-directory: typescript
steps:
# Always deploy main's live site, whatever ref triggered the run.
- uses: actions/checkout@v7
with:
ref: main

# The version archives to overlay onto the live build.
- uses: actions/checkout@v7
id: archive
continue-on-error: true
with:
ref: website-snapshots
path: snapshots

- uses: pnpm/action-setup@v6
with:
package_json_file: typescript/package.json

- uses: actions/setup-node@v7
with:
node-version-file: typescript/.node-version
cache: pnpm
cache-dependency-path: typescript/pnpm-lock.yaml

- run: pnpm install --frozen-lockfile

- run: pnpm --filter @varar/website... build

# Fold the archived versions into the deploy so /v/<version>/ is served
# from the same Worker as the live site.
- name: Overlay version archives
if: steps.archive.outcome == 'success'
run: |
if [ -d ../snapshots/v ]; then
mkdir -p packages/website/dist/v
cp -R ../snapshots/v/. packages/website/dist/v/
fi

- uses: cloudflare/wrangler-action@v4
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: typescript/packages/website
wranglerVersion: "4"
packageManager: pnpm
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ pnpm workspace · biome · vitest (for the core's own tests) · knip · jscpd ·
`.github/workflows/` (`typescript.yml`, `python.yml`, `java.yml`, `ruby.yml`,
`rust.yml`, `dotnet.yml`, `go.yml` — all also trigger on `conformance/**`).
- **Trunk-based development.** We commit small, working increments straight to `main` — no long-lived feature branches. Keep each commit self-contained and green (build + tests pass), so trunk is always releasable.
- **Type-check is a separate gate.** vitest runs source through esbuild/tsx, which strips types without checking them — a fully green suite can still fail `tsc`. Run `pnpm -r build` (exit 0) before calling any change done, especially after touching a shared type, an AST node, or a package's public exports (new required fields and new exports are the usual culprits). Note `pnpm build` and `pnpm check` both exclude the website packages — the Starlight website is built separately via `pnpm --filter @varar/website... build`, run in two CI places: the `test` job (a PR gate — its `<Editor>` components assert every port in `languages.json` has example files, so a forgotten port fails the build) and the `deploy-website` job (which also deploys to https://varar.dev). The legacy `packages/website` is never built. To check the website locally: `pnpm --filter @varar/website build`.
- **Type-check is a separate gate.** vitest runs source through esbuild/tsx, which strips types without checking them — a fully green suite can still fail `tsc`. Run `pnpm -r build` (exit 0) before calling any change done, especially after touching a shared type, an AST node, or a package's public exports (new required fields and new exports are the usual culprits). Note `pnpm build` and `pnpm check` both exclude the website packages — the Starlight website is built separately via `pnpm --filter @varar/website... build`, run in two CI places: the `test` job in `typescript.yml` (a PR gate — its `<Editor>` components assert every port in `languages.json` has example files, so a forgotten port fails the build) and the `deploy` job in `website.yml` (which also deploys to https://varar.dev, and overlays the versioned archive served under `/v/<version>/` — see `doc/website-versioning.md`). The legacy `packages/website` is never built. To check the website locally: `pnpm --filter @varar/website build`.
- `pnpm -r build` only type-checks each package's `src/` (its `tsconfig.json` emits with `rootDir: src`). **Test files (`tests/**`) are type-checked by `pnpm typecheck`** (root `tsconfig.tests.json`, `noEmit`, covers every non-website package's `tests/`). It's part of `pnpm check`, so run `pnpm check` (or `pnpm typecheck` alone) after touching tests — a green vitest run does *not* mean the tests type-check. Note `expectTypeOf` assertions are validated here by `tsc`, not by vitest (we don't run `vitest --typecheck`).
- **Dogfood specs**: `examples/typescript-vitest` (package
`@varar/example-typescript-vitest`, a pnpm workspace member via
Expand Down
92 changes: 92 additions & 0 deletions doc/website-versioning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Website version history

Readers can browse the website as it shipped for any past release at
`https://varar.dev/v/<version>/` — for example
[`https://varar.dev/v/0.7.0/`](https://varar.dev/v/0.7.0/). Each archived version
is **build-accurate**: not just the docs prose but the whole site, including the
interactive `<Editor>` components running exactly the `@varar/varar` code that
shipped in that release. A "Version history" link in the site footer, and the
hub page at [`/v/`](https://varar.dev/v/), list every archived version.

Only the live site is meant to rank in search: every archived page (and the
`/v/` hub) carries `<meta name="robots" content="noindex, follow">`, and the
site sitemap lists live pages only. A reader who lands on an archived page — via
a direct link or a bookmark — gets a warning banner across the top ("You're
reading the docs for Varar vX, an older release. View the latest →") linking to
the same page on the live site.

## Why build-accurate, not content-only

There is a Starlight plugin (`starlight-versions`) that snapshots docs *content*
into path-prefixed folders. We deliberately don't use it: it freezes Markdown
only, so an archived page's live editor would still run today's core. For a tool
whose whole pitch is "the prose IS the executable spec," that would be a lie —
the v0.5 page would show v0.5 words but v0.9 behaviour. Instead we build each
release tag from its own source and serve the whole thing under a path prefix.

## How it fits together

Everything is one Cloudflare Worker (`packages/website/wrangler.jsonc`) serving
static assets, so a versioned build has to live *inside* the deployed `dist/`.
The pieces:

- **`packages/website/astro.config.mjs`** reads `VARAR_SITE_BASE`. When set (e.g.
`/v/0.7.0/`), Astro/Vite rewrite every internal link, emitted asset, and
web-worker/wasm URL to sit under that prefix, and the build gets the `noindex`
robots tag. Unset ⇒ the normal, indexable root build.

- **`packages/website/src/components/Banner.astro`** and **`Footer.astro`** are
Starlight overrides. In a snapshot build (base set) the Banner shows the
old-version warning, its "view the latest" link built as an absolute URL via
`Astro.site` so the link rebaser leaves it pointing at the live site. The
Footer adds the "Version history" link to `/v/`. Both are inert on the live
build.

- **`release/website-rebase-links.mjs`** fixes the one thing Astro won't:
hand-written root-absolute links in the Markdown (`[sensors](/reference/sensors)`).
It rewrites them under the version prefix in the built HTML, so an archived
version never silently links back out to the live site. Links in the `/v/`
archive namespace are left alone (so a "Version history" link from inside
`/v/0.7.0/` still points at the live hub, not `/v/0.7.0/v/`).

- **`release/website-snapshot.sh <version> <out-dir> [<src-root>]`** ties those
together: build `<src-root>` (a checkout of the release tag) under base
`/v/<version>/`, rebase links, and copy the result to `<out-dir>`.

- **`release/website-ensure-base.mjs`** is a backfill safety net: it injects
`VARAR_SITE_BASE` support into an *older* tag's config that predates it. A
no-op on any tag from this change onward.

- **`release/website-index.mjs`** generates the `/v/` hub (`index.html` +
`versions.json`) by scanning the archived version folders.

- **`.github/workflows/website.yml`** orchestrates it. Archives are stored on an
orphan **`website-snapshots`** branch — built assets for every release, kept
off `main`'s history. The `snapshot` job (on a `v*` tag push, or manual
backfill) builds the version from a worktree of its tag and commits it to that
branch. The `deploy` job builds the live site, overlays the archive branch's
`/v/` into `dist/`, and deploys — so a freshly-tagged release ships in the same
run.

## Backfilling old releases

New releases are archived automatically when their tag is pushed. To archive the
releases that predate this system, run the **Website** workflow manually
(`workflow_dispatch`):

- Leave **version** blank to archive every release tag that has no snapshot yet.
- Or set it to a single bare semver (e.g. `0.6.1`) to (re)build just that one.

The job builds each missing version from a worktree of its tag — using its own
lockfile and toolchain, so the archive is faithful to what shipped — and pushes
the results to `website-snapshots`; the deploy then serves them. Very old tags
whose config or build differs substantially may need a hand; `website-ensure-base.mjs`
covers the common case (a config that simply predates `VARAR_SITE_BASE`).

## Alternatives considered

- **Subdomain per release** (`v0-7-0.varar.dev`): cleaner isolation but needs
wildcard DNS + a route per version. Path-per-release keeps one domain and one
Worker.
- **Rebuilding every tag on each deploy**: too slow and flaky. Archives are built
once and stored, then re-served on every deploy.
45 changes: 45 additions & 0 deletions release/website-ensure-base.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#!/usr/bin/env node
// Make a website Astro config honour VARAR_SITE_BASE, in place and idempotently.
//
// The current config already reads VARAR_SITE_BASE (see astro.config.mjs). This
// script exists for BACKFILL: building an *old* release tag whose config predates
// that support. Checked out at such a tag, the config ignores the env var and
// would emit root-absolute asset/link URLs that break under /v/<version>/. This
// injects the base wiring so the historical snapshot builds correctly, without
// editing the tag itself (the checkout is a throwaway worktree).
//
// Idempotent: if the config already mentions VARAR_SITE_BASE, it is left as-is,
// so running it against a modern tag is a no-op.
//
// Usage: node release/website-ensure-base.mjs <path-to-astro.config.mjs>
import { readFile, writeFile } from 'node:fs/promises'

const configPath = process.argv[2]
if (!configPath) {
console.error('usage: website-ensure-base.mjs <astro.config.mjs>')
process.exit(1)
}

const src = await readFile(configPath, 'utf8')

if (src.includes('VARAR_SITE_BASE')) {
console.log('config already base-aware; nothing to do')
process.exit(0)
}

// Insert `base: process.env.VARAR_SITE_BASE || undefined,` as the first property
// of the defineConfig({ … }) object. Every website config back to the first
// release opens its config with this exact call, so this anchor is stable.
const anchor = 'defineConfig({'
const at = src.indexOf(anchor)
if (at === -1) {
console.error(`could not find "${anchor}" in ${configPath}`)
process.exit(1)
}

const insertAt = at + anchor.length
const next = `${src.slice(0, insertAt)}\n base: process.env.VARAR_SITE_BASE || undefined,${src.slice(
insertAt,
)}`
await writeFile(configPath, next)
console.log(`injected VARAR_SITE_BASE support into ${configPath}`)
Loading