diff --git a/.clusterfuzzlite/Dockerfile b/.clusterfuzzlite/Dockerfile
index 8b7399c1..1627a29c 100644
--- a/.clusterfuzzlite/Dockerfile
+++ b/.clusterfuzzlite/Dockerfile
@@ -7,8 +7,8 @@
# `website/`, `node_modules/` and `.git/` out of the context.
#
# base-builder-go currently ships Go 1.25.0 while go.mod asks for 1.26.0 with
-# `toolchain go1.27.1`. GOTOOLCHAIN is `auto` in this image, so the go command
-# downloads go1.27.1 from the module proxy on first use. That is why
+# `toolchain go1.27.2`. GOTOOLCHAIN is `auto` in this image, so the go command
+# downloads go1.27.2 from the module proxy on first use. That is why
# proxy.golang.org and sum.golang.org are in the egress allow-lists of both
# ClusterFuzzLite workflows — without them the toolchain switch fails before a
# single fuzzer is built.
diff --git a/.coderabbit.yaml b/.coderabbit.yaml
index 62aab68d..efbccb26 100644
--- a/.coderabbit.yaml
+++ b/.coderabbit.yaml
@@ -89,6 +89,10 @@ reviews:
- "!website/public/**"
- "!docs/public/**"
- "!CHANGELOG.md"
+ # Docs archives are frozen copies of older releases, held to their release
+ # tag by scripts/docs-archive-config-test.sh. Review the provenance file
+ # (docs/content/archive-provenance.json) and that contract, not the copy.
+ - "!docs/content/docs/v*/**"
path_instructions:
- path: "internal/auth/**"
diff --git a/.github/workflows/ci-verify.yml b/.github/workflows/ci-verify.yml
index 2f07c7d5..1bb35f86 100644
--- a/.github/workflows/ci-verify.yml
+++ b/.github/workflows/ci-verify.yml
@@ -427,6 +427,12 @@ jobs:
- name: Verify the package release contract
run: bash scripts/package-release-config-test.sh
+ # Archives under docs/content/docs/vX.Y/ must still be byte copies of the
+ # docs at their recorded release tag. Needs the full-depth checkout above
+ # for the tags; with no archives it only checks the rules that need none.
+ - name: Verify the docs archive contract
+ run: bash scripts/docs-archive-config-test.sh
+
# Drives the Compose containerd check against a fake `go` on PATH and
# canned build info, so it needs no toolchain and no network.
- name: Verify the Compose containerd check
diff --git a/.github/workflows/quality-fuzz-cflite-pr.yml b/.github/workflows/quality-fuzz-cflite-pr.yml
index acb8e077..fe418519 100644
--- a/.github/workflows/quality-fuzz-cflite-pr.yml
+++ b/.github/workflows/quality-fuzz-cflite-pr.yml
@@ -67,7 +67,7 @@ run-name: >-
# StepSecurity insights link lists what was actually attempted; add the host
# there rather than guessing. storage.googleapis.com is for the gcr.io blob
# layers, and proxy.golang.org/sum.golang.org carry both the modules and the
-# go1.27.1 toolchain that base-builder-go's Go 1.25.0 downloads on first use.
+# go1.27.2 toolchain that base-builder-go's Go 1.25.0 downloads on first use.
#
# The three artifact hosts (*.actions.githubusercontent.com,
# results-receiver.actions.githubusercontent.com, *.blob.core.windows.net) are
@@ -106,7 +106,7 @@ jobs:
code-change-fuzz:
name: "ClusterFuzzLite (code change)"
runs-on: ubuntu-24.04
- # Pulling base-builder-go, downloading the go1.27.1 toolchain, building ten
+ # Pulling base-builder-go, downloading the go1.27.2 toolchain, building ten
# libFuzzer targets, and 300 seconds of fuzzing. 30 minutes is roughly
# double the expected wall time.
timeout-minutes: 30
diff --git a/.github/workflows/quality-integration-engines.yml b/.github/workflows/quality-integration-engines.yml
index b1627839..19c42b8e 100644
--- a/.github/workflows/quality-integration-engines.yml
+++ b/.github/workflows/quality-integration-engines.yml
@@ -16,7 +16,7 @@ run-name: >-
# that way, so the two lanes can never drift into testing different things.
#
# Why these pins. internal/docker/client.go falls back to API v1.44 when
-# version negotiation fails, and docs/content/docs/api-reference.mdx and
+# version negotiation fails, and docs/content/docs/current/api-reference.mdx and
# standalone-mode.mdx both publish v1.44 as the floor. Docker Engine 25.0 is
# the oldest release that serves API v1.44 (24.0 serves v1.43), so 25.0 is the
# oldest Engine the documented contract covers and it anchors the bottom of
diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml
index 11fb0a67..8abc8c2c 100644
--- a/.github/workflows/release-cut.yml
+++ b/.github/workflows/release-cut.yml
@@ -221,6 +221,11 @@ jobs:
- name: Verify the package release contract against the tag
run: bash scripts/package-release-config-test.sh
+ # A cut that moves current/ to a new line must already carry the archive
+ # of the line it leaves, built from that line's last tag.
+ - name: Verify the docs archive contract against the tag
+ run: bash scripts/docs-archive-config-test.sh
+
- name: Create and push release tag
env:
RELEASE_TAG: ${{ steps.next.outputs.release_tag }}
diff --git a/BENCHMARKS.md b/BENCHMARKS.md
index 7073a300..86224e38 100644
--- a/BENCHMARKS.md
+++ b/BENCHMARKS.md
@@ -95,7 +95,7 @@ the comment block at the top of
- **Runner:** GitHub-hosted `ubuntu-24.04`, `cpu: Intel(R) Xeon(R) 6973P-C`
(`goos: linux`, `goarch: amd64`).
- **Go:** `go1.26.6` — the toolchain `go.mod` pinned on `main` at that commit.
- `dev/v0.9` has since moved to `toolchain go1.27.1`; the next monthly run
+ `dev/v0.9` has since moved to `toolchain go1.27.2`; the next monthly run
will record numbers on that toolchain, and `benchstat`'s hardware-match
walk-back means a run on a new toolchain still compares fine as long as the
CPU model matches.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6350ccd2..f8cdb2b2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [v0.9.25] - 2026-10-09
+
+### Security
+
+- Update the Go toolchain and builder images to 1.27.2, which fixes 13
+ standard-library advisories: GO-2026-6599, GO-2026-6600, GO-2026-6603,
+ GO-2026-6604, GO-2026-6605, GO-2026-6607, GO-2026-6608, GO-2026-6609,
+ GO-2026-6610, GO-2026-6611, GO-2026-6612, GO-2026-6613, and GO-2026-6617.
+ `govulncheck` reported ten of them as reachable from Portwing's code on Go
+ 1.27.1, which v0.9.24 was built with.
+ The lint script moves to golangci-lint v2.14.0, because v2.13.2 cannot read
+ the export data Go 1.27.2 writes.
+
+### Changed
+
+- The documentation source moved to `docs/content/docs/current/`. Every
+ published `/docs/...` URL is unchanged. This prepares for archiving each
+ release line's docs at `/docs/vX.Y/` from v1.0.
+
## [v0.9.24] - 2026-10-08
### Fixed
diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md
index 85d4f333..3fa46e2a 100644
--- a/COMPATIBILITY.md
+++ b/COMPATIBILITY.md
@@ -17,7 +17,7 @@ DrydockCompat bumps.
| Portwing version | Drydock wire connection | Full feature compatibility | sockguard preset | Wire compat (`DrydockCompat` / `serverCompatLevel`) |
|---|---|---|---|---|
-| v0.9.24 (latest release) / `main` | 1.6.x; 1.5.x with `DD_EXPERIMENTAL_PORTWING=true` | `v1.6.0-rc.11+` | `portwing.yaml`, `portwing-with-exec.yaml`, `portwing-with-compose.yaml` | `1.4.0` |
+| v0.9.25 (latest release) / `main` | 1.6.x; 1.5.x with `DD_EXPERIMENTAL_PORTWING=true` | `v1.6.0-rc.11+` | `portwing.yaml`, `portwing-with-exec.yaml`, `portwing-with-compose.yaml` | `1.4.0` |
## Wire compatibility versus feature compatibility
@@ -92,6 +92,6 @@ both together when either changes.
- Portwing: [`STABILITY.md`](STABILITY.md) — semantic-versioning, deprecation,
and change-control guarantees for the HTTP, environment, MCP, and wire
surfaces.
-- Portwing: [`docs/drydock-integration.md`](docs/drydock-integration.md) / [`docs/content/docs/drydock-integration.mdx`](docs/content/docs/drydock-integration.mdx) — full wire-protocol and REST/SSE contract detail.
+- Portwing: [`docs/drydock-integration.md`](docs/drydock-integration.md) / [`docs/content/docs/current/drydock-integration.mdx`](docs/content/docs/current/drydock-integration.mdx) — full wire-protocol and REST/SSE contract detail.
- Drydock: see its README's ecosystem section.
- sockguard: see its README's ecosystem section.
diff --git a/Dockerfile b/Dockerfile
index bdf20ebd..1ad190b7 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -3,7 +3,7 @@
# Dockerfile.release; this file is the standalone equivalent.)
# Stage 1: Build the binary from source.
-FROM golang:1.27.1-alpine@sha256:cf6fca6641884b8433441b2b0652976f975e1d0fdd26d177eaaf8596087f3125 AS builder
+FROM golang:1.27.2-alpine@sha256:85dc1069ac644ea3c527b177303a406eb3358192816cd7f9e5848eb658851673 AS builder
RUN apk add --no-cache git
WORKDIR /src
COPY go.mod go.sum ./
diff --git a/Dockerfile.armv7 b/Dockerfile.armv7
index e4c2f494..cf930b82 100644
--- a/Dockerfile.armv7
+++ b/Dockerfile.armv7
@@ -4,7 +4,7 @@
# (Release images are built by GoReleaser via the unified Dockerfile.release.)
# Stage 1: Build the binary from source.
-FROM --platform=$BUILDPLATFORM golang:1.27.1-alpine@sha256:cf6fca6641884b8433441b2b0652976f975e1d0fdd26d177eaaf8596087f3125 AS builder
+FROM --platform=$BUILDPLATFORM golang:1.27.2-alpine@sha256:85dc1069ac644ea3c527b177303a406eb3358192816cd7f9e5848eb658851673 AS builder
RUN apk add --no-cache git
WORKDIR /src
COPY go.mod go.sum ./
@@ -13,7 +13,7 @@ COPY . .
RUN CGO_ENABLED=0 GOARCH=arm GOARM=7 go build -trimpath -ldflags="-s -w" -o /portwing ./cmd/portwing
# Compose v5.5.1 with the containerd security patch, isolated from Portwing modules.
-FROM --platform=$BUILDPLATFORM golang:1.27.1-alpine@sha256:cf6fca6641884b8433441b2b0652976f975e1d0fdd26d177eaaf8596087f3125 AS compose-builder
+FROM --platform=$BUILDPLATFORM golang:1.27.2-alpine@sha256:85dc1069ac644ea3c527b177303a406eb3358192816cd7f9e5848eb658851673 AS compose-builder
ADD --checksum=sha256:c72877db37172d8ee55f565e4fed20067af89015e986b190768fd4ee621025f2 https://github.com/docker/compose/archive/5f94fb0aa42a2cd1248c6e6c7fafb87546b9c8de.tar.gz /tmp/compose.tar.gz
WORKDIR /compose
RUN tar -xzf /tmp/compose.tar.gz --strip-components=1 \
diff --git a/Dockerfile.dev b/Dockerfile.dev
index ec59ca85..d47c3bd4 100644
--- a/Dockerfile.dev
+++ b/Dockerfile.dev
@@ -1,4 +1,4 @@
-FROM golang:1.27.1-alpine@sha256:cf6fca6641884b8433441b2b0652976f975e1d0fdd26d177eaaf8596087f3125
+FROM golang:1.27.2-alpine@sha256:85dc1069ac644ea3c527b177303a406eb3358192816cd7f9e5848eb658851673
# Mirror the release images: dedicated portwing user (UID 65532). Reaching
# the host Docker socket requires --group-add with the socket's GID.
RUN apk add --no-cache git docker-cli docker-compose \
diff --git a/Dockerfile.release b/Dockerfile.release
index 6f6285ba..c6883395 100644
--- a/Dockerfile.release
+++ b/Dockerfile.release
@@ -15,7 +15,7 @@
# linux/arm/v7/portwing).
# Compose v5.5.1 with the containerd security patch, isolated from Portwing modules.
-FROM --platform=$BUILDPLATFORM golang:1.27.1-alpine@sha256:cf6fca6641884b8433441b2b0652976f975e1d0fdd26d177eaaf8596087f3125 AS compose-builder
+FROM --platform=$BUILDPLATFORM golang:1.27.2-alpine@sha256:85dc1069ac644ea3c527b177303a406eb3358192816cd7f9e5848eb658851673 AS compose-builder
ADD --checksum=sha256:c72877db37172d8ee55f565e4fed20067af89015e986b190768fd4ee621025f2 https://github.com/docker/compose/archive/5f94fb0aa42a2cd1248c6e6c7fafb87546b9c8de.tar.gz /tmp/compose.tar.gz
WORKDIR /compose
RUN tar -xzf /tmp/compose.tar.gz --strip-components=1 \
diff --git a/README.md b/README.md
index d329875e..22e55ef0 100644
--- a/README.md
+++ b/README.md
@@ -32,12 +32,12 @@
> [!WARNING]
-> **Pre-1.0 software — APIs may still change.** Portwing is pre-`v1.0.0` (currently `v0.9.24`). The compatibility guarantees that already apply are published in [STABILITY.md](STABILITY.md); other surfaces may still change between minor releases. Pin to an exact version and review the [CHANGELOG](CHANGELOG.md) before upgrading.
+> **Pre-1.0 software — APIs may still change.** Portwing is pre-`v1.0.0` (currently `v0.9.25`). The compatibility guarantees that already apply are published in [STABILITY.md](STABILITY.md); other surfaces may still change between minor releases. Pin to an exact version and review the [CHANGELOG](CHANGELOG.md) before upgrading.
> [!NOTE]
-> **v0.9.24 is the current release.** Exec sessions on the edge now end cleanly when one of their goroutines panics: a failed start gets an audit record, the session is closed, and the controller is told once. The comparison data and README were also corrected. Binary request uploads require a controller that implements the negotiated `edge-request-body-stream` capability. Wire compatibility remains `portwing/1.0` and `DrydockCompat` 1.4.0; full watcher/update feature compatibility requires Drydock `v1.6.0-rc.11+`. See [CHANGELOG.md](CHANGELOG.md) for the full itemized history.
+> **v0.9.25 is the current release.** It is built with Go 1.27.2, which fixes 13 standard-library advisories; ten of them were reachable from Portwing's code in the previous release, so upgrading is recommended. Binary request uploads require a controller that implements the negotiated `edge-request-body-stream` capability. Wire compatibility remains `portwing/1.0` and `DrydockCompat` 1.4.0; full watcher/update feature compatibility requires Drydock `v1.6.0-rc.11+`. See [CHANGELOG.md](CHANGELOG.md) for the full itemized history.
Contents
@@ -160,7 +160,7 @@ services:
- SOCKGUARD_LISTEN_SOCKET=/var/run/sockguard/sockguard.sock
portwing:
- image: ghcr.io/codeswhat/portwing:0.9.24
+ image: ghcr.io/codeswhat/portwing:0.9.25
restart: unless-stopped
depends_on:
- sockguard
@@ -215,7 +215,7 @@ sudo chown 65532:65532 portwing_ed25519.pem && sudo chmod 0400 portwing_ed25519.
```yaml
services:
portwing:
- image: ghcr.io/codeswhat/portwing:0.9.24
+ image: ghcr.io/codeswhat/portwing:0.9.25
restart: unless-stopped
read_only: true
cap_drop:
@@ -255,10 +255,10 @@ Stable releases also ship a Homebrew cask plus signed/checksummed `deb` and
brew install --cask codeswhat/tap/portwing
# Debian/Ubuntu (after downloading the matching release asset)
-sudo apt install ./portwing_0.9.24_linux_amd64.deb
+sudo apt install ./portwing_0.9.25_linux_amd64.deb
# Fedora/RHEL (after downloading the matching release asset)
-sudo rpm --install ./portwing_0.9.24_linux_amd64.rpm
+sudo rpm --install ./portwing_0.9.25_linux_amd64.rpm
```
Packages install the command and, on Linux, a hardened `portwing.service`; they
@@ -281,7 +281,7 @@ docker run -d \
-v /var/run/docker.sock:/var/run/docker.sock \
-p 127.0.0.1:3000:3000 \
-e TOKEN=$(openssl rand -hex 24) \
- ghcr.io/codeswhat/portwing:0.9.24
+ ghcr.io/codeswhat/portwing:0.9.25
```
Portwing now fails closed: Standard mode refuses to start without `TOKEN`,
diff --git a/RELEASING.md b/RELEASING.md
index 76a78384..6454831f 100644
--- a/RELEASING.md
+++ b/RELEASING.md
@@ -43,7 +43,7 @@
6. **No source version bump needed** — the binary's version is injected at build time via GoReleaser ldflags (`-X github.com/codeswhat/portwing/internal/protocol.AgentVersion={{.Version}}`). `AgentVersion` in `internal/protocol/version.go` must stay a `var`: `-X` silently does nothing to a `const`.
-7. **Lefthook pre-push** — runs automatically on `git push`. Sequence: clean-tree → release contract (`scripts/package-release-config-test.sh`) → goreleaser snapshot → lint → Qlty → test (-race) → govulncheck → fuzz smoke → actionlint → zizmor → web (`npm run check:web`). The push is blocked if any step fails.
+7. **Lefthook pre-push** — runs automatically on `git push`. Sequence: clean-tree → release contract (`scripts/package-release-config-test.sh`) and docs archive contract (`scripts/docs-archive-config-test.sh`) → goreleaser snapshot → lint → Qlty → test (-race) → govulncheck → fuzz smoke → actionlint → zizmor → web (`npm run check:web`). The push is blocked if any step fails.
---
@@ -87,6 +87,36 @@ sit at "Expected" forever. So the order is:
Read the effective ruleset back afterward rather than trusting the PATCH's
200. The script prints it.
+## Cutting a docs archive
+
+Do this in the prep PR for a new minor or major line (v0.9 to v1.0, say),
+never for a patch. The old line's docs get frozen as `docs/content/docs/vX.Y/`
+and `current/` moves on to the new line. Layout and rules:
+`docs/content/README.md`.
+
+1. Make sure the old line is finished. The archive comes from its **last** tag,
+ so the final patch has to be tagged and fetched (`git fetch --tags`).
+2. Retitle `docs/content/docs/current/meta.json` to the new line. The cut
+ refuses to archive the line `current/` still names.
+3. Cut it from that tag:
+
+ ```sh
+ npm run docs:archive -- vX.Y.Z # the old line's last tag
+ ```
+
+ This reads `docs/content/docs/current` from the tag with git (never your
+ working tree), writes `docs/content/docs/vX.Y/`, adds the entry to
+ `docs/content/archive-provenance.json`, and lists `vX.Y` in
+ `docs/content/docs/meta.json` after `current`.
+4. Run `bash scripts/docs-archive-config-test.sh` after committing the new
+ directory and the provenance entry together. The same contract runs in
+ `Release Contract`, `release-cut`, and pre-push; once the changelog's
+ previous release is on an older line than `current/`, it fails until that
+ line's archive exists.
+
+Never edit a file under an archived `vX.Y/`. A correction goes in `current/`;
+an archive that no longer matches its tag fails the contract.
+
## Cutting the tag
**Preferred path: use the `release-cut` workflow.**
@@ -199,7 +229,7 @@ the Git-backed deployment or update its GitHub status.
**ARMv7 runtime.** Wolfi publishes no armv7 repository, so this image retains
Alpine 3.24 release metadata and CA certificates alongside static binaries.
Docker CLI 29.8.0 is an official download pinned by SHA256. Compose is built
- from checksum-pinned upstream 5.5.1 source with containerd 2.3.6 and Go 1.27.1,
+ from checksum-pinned upstream 5.5.1 source with containerd 2.3.6 and Go 1.27.2,
identifies itself as `v5.5.1-portwing.2`, and runs directly or as a CLI plugin.
Both ARM recipes omit BusyBox; image health checks use `portwing healthcheck`
for HTTP/TLS probes, with HTTPS pinned to the configured `TLS_CERT` leaf.
diff --git a/ROADMAP.md b/ROADMAP.md
index e68b4e8d..3d3ff626 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,6 +1,6 @@
# Portwing Roadmap
-> Portwing is pre-`v1.0.0` software (currently `v0.9.24`). This roadmap describes
+> Portwing is pre-`v1.0.0` software (currently `v0.9.25`). This roadmap describes
> direction and priorities — not commitments. Items and ordering may change
> between releases. For the authoritative record of what has shipped, see the
> [CHANGELOG](CHANGELOG.md).
@@ -88,13 +88,14 @@ calendar date:
- **Completed for v0.9 — Edge audit export hardening.** Standard and Edge
modes share one exporter implementation; public docs and Kubernetes examples
explicitly isolate Edge Mode's unauthenticated operations listener.
-- **Docs stay current-only until v1.0.** `docs/content/docs/` is a single flat
- tree tracking the latest release, with no per-version archive. That's
- deliberate through `v0.9.x`. The v1.0.0 release-cut PR is the trigger to
- decide whether the semver guarantees in [STABILITY.md](STABILITY.md) need a
- versioned docs archive — e.g. drydock's `content/docs/{current,v1.x}` split
- plus `archive-provenance.json` and its release-docs-archive tests — before
- v1.0 ships.
+- **Versioned docs archive at v1.0.** `docs/content/docs/current/` holds the
+ tree tracking the latest release, served at the unversioned `/docs/...`
+ URLs. Through `v0.9.x` it is the only tree. At `v1.0.0` the `v0.9` docs are
+ archived from the final `v0.9.x` tag into a sibling `v0.9/` directory,
+ served at `/docs/v0.9/...` and verified against that tag's tree, so the
+ semver guarantees in [STABILITY.md](STABILITY.md) keep the docs they were
+ written against. Each later minor line archives the one before it the same
+ way.
- **SLSA Build L2 is the ceiling for now.** Provenance attestation (see
[RELEASING.md](RELEASING.md)) stays at Build L2 because GoReleaser, cosign,
and attest all run inline inside `release.yml`. Build L3 needs an isolated,
diff --git a/SECURITY-ASSURANCE.md b/SECURITY-ASSURANCE.md
index a9d95b36..6eceea4d 100644
--- a/SECURITY-ASSURANCE.md
+++ b/SECURITY-ASSURANCE.md
@@ -153,7 +153,7 @@ Evidence: [`.github/workflows/ci-verify.yml`](.github/workflows/ci-verify.yml),
[`.github/workflows/quality-fuzz-cflite-prune.yml`](.github/workflows/quality-fuzz-cflite-prune.yml),
[`.github/workflows/release.yml`](.github/workflows/release.yml), the public
[coverage report](https://qlty.sh/gh/CodesWhat/projects/portwing), and
-[`docs/content/docs/verification.mdx`](docs/content/docs/verification.mdx).
+[`docs/content/docs/current/verification.mdx`](docs/content/docs/current/verification.mdx).
The scheduled lanes also record what they measured, not only whether they
passed. The weekly RSS-growth soak and the monthly mutation matrix each append
diff --git a/SPEC.md b/SPEC.md
index 6bc8d631..f792b259 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -632,7 +632,7 @@ Chainguard Wolfi OS packages assembled into a `FROM scratch` image (Alpine on ar
Packages:
- **Wolfi (amd64/arm64):** `ca-certificates-bundle`, `busybox`, `docker-cli`, `docker-compose`, `wget`
-- **Alpine (armv7):** `ca-certificates-bundle` and `alpine-release`, with `alpine-keys` as a dependency. Docker CLI 29.8.0 is an official static ARMv7 download pinned by SHA256. Compose is built from checksum-pinned upstream 5.5.1 source with containerd 2.3.6 and Go 1.27.1, identifies itself as `v5.5.1-portwing.2`, and is available directly and as a Docker CLI plugin. Both ARM image recipes omit BusyBox and use `portwing healthcheck` for HTTP/TLS probes.
+- **Alpine (armv7):** `ca-certificates-bundle` and `alpine-release`, with `alpine-keys` as a dependency. Docker CLI 29.8.0 is an official static ARMv7 download pinned by SHA256. Compose is built from checksum-pinned upstream 5.5.1 source with containerd 2.3.6 and Go 1.27.2, identifies itself as `v5.5.1-portwing.2`, and is available directly and as a Docker CLI plugin. Both ARM image recipes omit BusyBox and use `portwing healthcheck` for HTTP/TLS probes.
## 15. Migration Strategy
diff --git a/api/openapi.yaml b/api/openapi.yaml
index 043da6f9..39895bf9 100644
--- a/api/openapi.yaml
+++ b/api/openapi.yaml
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: Portwing Agent API
- version: 0.9.24
+ version: 0.9.25
description: |
Portwing is a lightweight remote Docker agent for the Drydock container
monitoring platform. This specification covers the **Standard Mode** HTTP API
@@ -158,7 +158,7 @@ components:
version:
type: string
description: Portwing agent version.
- example: "0.9.24"
+ example: "0.9.25"
uptimeSeconds:
type: number
minimum: 0
@@ -179,7 +179,7 @@ components:
version:
type: string
description: Portwing agent version
- example: "0.9.24"
+ example: "0.9.25"
dockerVersion:
type: string
description: Docker Engine version string
@@ -553,7 +553,7 @@ components:
properties:
agentVersion:
type: string
- example: "0.9.24"
+ example: "0.9.25"
protocolName:
type: string
example: "portwing"
@@ -1213,7 +1213,7 @@ paths:
**Example stream:**
```
- data: {"type":"dd:ack","data":{"version":"0.9.24","os":"linux","arch":"amd64","cpus":4,"memoryGb":0,"uptimeSeconds":3600,"lastSeen":"2024-07-23T10:00:00Z","containers":{"total":5,"running":4,"stopped":1},"images":3}}
+ data: {"type":"dd:ack","data":{"version":"0.9.25","os":"linux","arch":"amd64","cpus":4,"memoryGb":0,"uptimeSeconds":3600,"lastSeen":"2024-07-23T10:00:00Z","containers":{"total":5,"running":4,"stopped":1},"images":3}}
data: {"type":"dd:watcher-snapshot","data":{"watcher":{...WatcherComponent...},"containers":[{...Container...}]}}
diff --git a/cmd/portwing/main.go b/cmd/portwing/main.go
index 05e2a3fc..e630b9f8 100644
--- a/cmd/portwing/main.go
+++ b/cmd/portwing/main.go
@@ -165,7 +165,7 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
// printUsage writes top-level CLI usage to w. Portwing is configured
// entirely through environment variables rather than flags; see
-// docs/content/docs/configuration.mdx for the full reference.
+// docs/content/docs/current/configuration.mdx for the full reference.
func printUsage(w io.Writer) {
fmt.Fprintln(w, "Usage: portwing [command]")
fmt.Fprintln(w)
diff --git a/docs/assets/star-history-dark.svg b/docs/assets/star-history-dark.svg
index 2f60a1fc..f7d7c13f 100644
--- a/docs/assets/star-history-dark.svg
+++ b/docs/assets/star-history-dark.svg
@@ -1,5 +1,5 @@
-
- Star history for CodesWhat/portwing — 4 stars
+
+ Star history for CodesWhat/portwing — 5 stars
@@ -8,14 +8,14 @@
CodesWhat/portwing
- Jun 12 – Aug 10, 2026
- 4
+ Jun 12 – Oct 7, 2026
+ 5
STARS
-
+
-
-
-
- Jun 12 Jun 27 Jul 12 Jul 26 Aug 10
- 1 2 3 4
+
+
+
+ Jun Jul Aug Sep Oct
+ 2 4
diff --git a/docs/assets/star-history.svg b/docs/assets/star-history.svg
index e3a8e130..e0a76f24 100644
--- a/docs/assets/star-history.svg
+++ b/docs/assets/star-history.svg
@@ -1,5 +1,5 @@
-
- Star history for CodesWhat/portwing — 4 stars
+
+ Star history for CodesWhat/portwing — 5 stars
@@ -8,14 +8,14 @@
CodesWhat/portwing
- Jun 12 – Aug 10, 2026
- 4
+ Jun 12 – Oct 7, 2026
+ 5
STARS
-
+
-
-
-
- Jun 12 Jun 27 Jul 12 Jul 26 Aug 10
- 1 2 3 4
+
+
+
+ Jun Jul Aug Sep Oct
+ 2 4
diff --git a/docs/content/README.md b/docs/content/README.md
new file mode 100644
index 00000000..83536145
--- /dev/null
+++ b/docs/content/README.md
@@ -0,0 +1,43 @@
+# Docs content layout
+
+This directory is not part of the docs site. Fumadocs ingests everything under
+`docs/` only, so keep files that must not become pages out of that directory.
+`docs/content/docs/` holds the pages; this directory holds the records about them.
+
+## Versions
+
+```text
+docs/content/docs/
+ meta.json lists the version roots: current first, then archives newest first
+ current/ the live docs, served at /docs/
+ vX.Y/ one frozen archive per retired release line, served at /docs/vX.Y/
+docs/content/archive-provenance.json where each archive came from
+```
+
+`current/meta.json` carries the line it documents as its title. That line has
+no archive until `current/` moves on to the next one.
+
+## Archives are immutable
+
+An archive is a byte-for-byte copy of `current/` as it was at the last release
+tag of its line. Nothing edits it afterwards, because the point of an archive
+is to show what that release shipped with. A mistake found later is fixed in
+`current/`.
+
+Each archive has an entry in `archive-provenance.json` with `sourceTag`,
+`sourceCommit`, `sourcePath` and `sourceTree`. `scripts/docs-archive-config-test.sh`
+checks that the committed archive is exactly that tree, that the tag resolves
+to that commit, and that the tag ends its line. Cut one with
+`npm run docs:archive -- `; the steps are in `RELEASING.md`.
+
+## How archives are published
+
+- Links inside an archive keep the form they had at release (`/authentication`).
+ The `remarkVersionedLinks` plugin in `docs/source.config.ts` scopes them to
+ `/vX.Y/...` at build time, so the copy stays identical to the tag.
+- Every archived page is `noindex`, shows an "archived, unsupported" notice
+ linking to the same page in the current docs, and is left out of the sitemap.
+- The static export puts an archive's pages at `docs/out/vX.Y/.html` and
+ its index at `docs/out/vX.Y.html`; `npm run docs:export-contract` checks both.
+- Analytics counts archived paths under `/_other`; the allowlist in
+ `analytics/src/contract.ts` covers current pages only.
diff --git a/docs/content/archive-provenance.json b/docs/content/archive-provenance.json
new file mode 100644
index 00000000..0967ef42
--- /dev/null
+++ b/docs/content/archive-provenance.json
@@ -0,0 +1 @@
+{}
diff --git a/docs/content/docs/api-reference.mdx b/docs/content/docs/current/api-reference.mdx
similarity index 99%
rename from docs/content/docs/api-reference.mdx
rename to docs/content/docs/current/api-reference.mdx
index 1a57d657..e2b64ba1 100644
--- a/docs/content/docs/api-reference.mdx
+++ b/docs/content/docs/current/api-reference.mdx
@@ -52,7 +52,7 @@ No authentication is required for the health endpoints. Use them for Docker `HEA
"live": true,
"ready": false,
"mode": "standard",
- "version": "0.9.24",
+ "version": "0.9.25",
"uptimeSeconds": 3842.7,
"docker": "unknown",
"controller": "not_applicable"
@@ -67,7 +67,7 @@ No authentication is required for the health endpoints. Use them for Docker `HEA
"live": true,
"ready": true,
"mode": "standard",
- "version": "0.9.24",
+ "version": "0.9.25",
"uptimeSeconds": 3842.7,
"docker": "connected",
"controller": "not_applicable"
@@ -81,7 +81,7 @@ edge mode, when the controller WebSocket is disconnected.
```json
{
- "version": "0.9.24",
+ "version": "0.9.25",
"dockerVersion": "27.0.3",
"mode": "standard",
"uptime": "2h34m56.789s",
@@ -358,7 +358,7 @@ Available when `ADAPTER=generic`. These endpoints expose container management wi
```json
{
- "agentVersion": "0.9.24",
+ "agentVersion": "0.9.25",
"protocolName": "portwing",
"protocolVersion": "1.0",
"adapter": "generic"
diff --git a/docs/content/docs/audit-logging.mdx b/docs/content/docs/current/audit-logging.mdx
similarity index 100%
rename from docs/content/docs/audit-logging.mdx
rename to docs/content/docs/current/audit-logging.mdx
diff --git a/docs/content/docs/authentication.mdx b/docs/content/docs/current/authentication.mdx
similarity index 100%
rename from docs/content/docs/authentication.mdx
rename to docs/content/docs/current/authentication.mdx
diff --git a/docs/content/docs/competitive-landscape.mdx b/docs/content/docs/current/competitive-landscape.mdx
similarity index 100%
rename from docs/content/docs/competitive-landscape.mdx
rename to docs/content/docs/current/competitive-landscape.mdx
diff --git a/docs/content/docs/configuration.mdx b/docs/content/docs/current/configuration.mdx
similarity index 100%
rename from docs/content/docs/configuration.mdx
rename to docs/content/docs/current/configuration.mdx
diff --git a/docs/content/docs/connection-modes.mdx b/docs/content/docs/current/connection-modes.mdx
similarity index 100%
rename from docs/content/docs/connection-modes.mdx
rename to docs/content/docs/current/connection-modes.mdx
diff --git a/docs/content/docs/drydock-integration.mdx b/docs/content/docs/current/drydock-integration.mdx
similarity index 100%
rename from docs/content/docs/drydock-integration.mdx
rename to docs/content/docs/current/drydock-integration.mdx
diff --git a/docs/content/docs/getting-started.mdx b/docs/content/docs/current/getting-started.mdx
similarity index 100%
rename from docs/content/docs/getting-started.mdx
rename to docs/content/docs/current/getting-started.mdx
diff --git a/docs/content/docs/index.mdx b/docs/content/docs/current/index.mdx
similarity index 100%
rename from docs/content/docs/index.mdx
rename to docs/content/docs/current/index.mdx
diff --git a/docs/content/docs/installation.mdx b/docs/content/docs/current/installation.mdx
similarity index 99%
rename from docs/content/docs/installation.mdx
rename to docs/content/docs/current/installation.mdx
index 5a40b3c8..a4d59e86 100644
--- a/docs/content/docs/installation.mdx
+++ b/docs/content/docs/current/installation.mdx
@@ -40,7 +40,7 @@ Download the package for your architecture from the
[GitHub release](https://github.com/CodesWhat/portwing/releases). For example:
```bash
-VERSION=0.9.24
+VERSION=0.9.25
gh release download "v${VERSION}" \
--repo CodesWhat/portwing \
--pattern "portwing_${VERSION}_linux_amd64.deb" \
@@ -59,7 +59,7 @@ Use `linux_arm64.deb` on 64-bit Arm or `linux_armv7.deb` on 32-bit Arm.
Download the matching package and its signature bundle:
```bash
-VERSION=0.9.24
+VERSION=0.9.25
gh release download "v${VERSION}" \
--repo CodesWhat/portwing \
--pattern "portwing_${VERSION}_linux_amd64.rpm" \
diff --git a/docs/content/docs/mcp-server.mdx b/docs/content/docs/current/mcp-server.mdx
similarity index 100%
rename from docs/content/docs/mcp-server.mdx
rename to docs/content/docs/current/mcp-server.mdx
diff --git a/docs/content/docs/current/meta.json b/docs/content/docs/current/meta.json
new file mode 100644
index 00000000..eee16183
--- /dev/null
+++ b/docs/content/docs/current/meta.json
@@ -0,0 +1,23 @@
+{
+ "title": "v0.9",
+ "root": true,
+ "pages": [
+ "index",
+ "getting-started",
+ "installation",
+ "connection-modes",
+ "authentication",
+ "configuration",
+ "stability-policy",
+ "security-model",
+ "drydock-integration",
+ "standalone-mode",
+ "mcp-server",
+ "observability",
+ "audit-logging",
+ "api-reference",
+ "verification",
+ "competitive-landscape",
+ "migrating-from-watchtower"
+ ]
+}
diff --git a/docs/content/docs/migrating-from-watchtower.mdx b/docs/content/docs/current/migrating-from-watchtower.mdx
similarity index 100%
rename from docs/content/docs/migrating-from-watchtower.mdx
rename to docs/content/docs/current/migrating-from-watchtower.mdx
diff --git a/docs/content/docs/observability.mdx b/docs/content/docs/current/observability.mdx
similarity index 98%
rename from docs/content/docs/observability.mdx
rename to docs/content/docs/current/observability.mdx
index d8f9e0f8..cb8f8bde 100644
--- a/docs/content/docs/observability.mdx
+++ b/docs/content/docs/current/observability.mdx
@@ -143,7 +143,7 @@ that remains at capacity while the exporter error counter rises.
```text
# HELP portwing_build_info Portwing agent build metadata.
# TYPE portwing_build_info gauge
-portwing_build_info{version="0.9.24"} 1
+portwing_build_info{version="0.9.25"} 1
# HELP portwing_uptime_seconds Seconds since the agent started.
# TYPE portwing_uptime_seconds gauge
portwing_uptime_seconds 3842.7
@@ -197,7 +197,7 @@ curl http://your-host:3000/health
Returns HTTP 200 with the full operational shape:
```json
-{"status":"ok","live":true,"ready":false,"mode":"standard","version":"0.9.24","uptimeSeconds":3842.7,"docker":"unknown","controller":"not_applicable"}
+{"status":"ok","live":true,"ready":false,"mode":"standard","version":"0.9.25","uptimeSeconds":3842.7,"docker":"unknown","controller":"not_applicable"}
```
### GET /ready
@@ -213,13 +213,13 @@ curl http://your-host:3000/ready
Standard-mode ready response:
```json
-{"status":"healthy","live":true,"ready":true,"mode":"standard","version":"0.9.24","uptimeSeconds":3842.7,"docker":"connected","controller":"not_applicable"}
+{"status":"healthy","live":true,"ready":true,"mode":"standard","version":"0.9.25","uptimeSeconds":3842.7,"docker":"connected","controller":"not_applicable"}
```
Edge-mode not-ready response:
```json
-{"status":"unhealthy","live":true,"ready":false,"mode":"edge","version":"0.9.24","uptimeSeconds":42.1,"docker":"connected","controller":"disconnected"}
+{"status":"unhealthy","live":true,"ready":false,"mode":"edge","version":"0.9.25","uptimeSeconds":42.1,"docker":"connected","controller":"disconnected"}
```
`GET /_portwing/health` remains as a compatibility alias for `/ready`.
diff --git a/docs/content/docs/security-model.mdx b/docs/content/docs/current/security-model.mdx
similarity index 99%
rename from docs/content/docs/security-model.mdx
rename to docs/content/docs/current/security-model.mdx
index 27d3deda..fa87cd5e 100644
--- a/docs/content/docs/security-model.mdx
+++ b/docs/content/docs/current/security-model.mdx
@@ -321,7 +321,7 @@ Every release is built in GitHub Actions with reproducible flags (`CGO_ENABLED=0
Verify a release before running it:
```bash
-VERSION=0.9.24
+VERSION=0.9.25
# Verify the checksums file
cosign verify-blob \
diff --git a/docs/content/docs/stability-policy.mdx b/docs/content/docs/current/stability-policy.mdx
similarity index 100%
rename from docs/content/docs/stability-policy.mdx
rename to docs/content/docs/current/stability-policy.mdx
diff --git a/docs/content/docs/standalone-mode.mdx b/docs/content/docs/current/standalone-mode.mdx
similarity index 99%
rename from docs/content/docs/standalone-mode.mdx
rename to docs/content/docs/current/standalone-mode.mdx
index 0b2830ed..f01c97f4 100644
--- a/docs/content/docs/standalone-mode.mdx
+++ b/docs/content/docs/current/standalone-mode.mdx
@@ -54,7 +54,7 @@ curl -s -H "Authorization: Bearer $TOKEN" \
```json
{
- "agentVersion": "0.9.24",
+ "agentVersion": "0.9.25",
"protocolName": "portwing",
"protocolVersion": "1.0",
"adapter": "generic"
@@ -155,7 +155,7 @@ curl -sf http://localhost:3000/_portwing/health
"live": true,
"ready": true,
"mode": "standard",
- "version": "0.9.24",
+ "version": "0.9.25",
"uptimeSeconds": 3842.7,
"docker": "connected",
"controller": "not_applicable"
diff --git a/docs/content/docs/verification.mdx b/docs/content/docs/current/verification.mdx
similarity index 95%
rename from docs/content/docs/verification.mdx
rename to docs/content/docs/current/verification.mdx
index b82c249b..703c0494 100644
--- a/docs/content/docs/verification.mdx
+++ b/docs/content/docs/current/verification.mdx
@@ -53,10 +53,10 @@ brew install gh # macOS
Every image pushed to `ghcr.io/codeswhat/portwing` is cosign keyless-signed by
the release workflow. Replace `TAG` with the version tag you are deploying
(the tag is the semver without the leading `v`, matching GoReleaser's output —
-e.g. `0.9.24`, not `v0.9.24`):
+e.g. `0.9.25`, not `v0.9.25`):
```bash
-TAG=0.9.24
+TAG=0.9.25
cosign verify \
--certificate-identity="https://github.com/CodesWhat/portwing/.github/workflows/release.yml@refs/tags/v${TAG}" \
@@ -75,7 +75,7 @@ to the container manifest. This uses the Sigstore Rekor ledger and is
independently verifiable without cosign:
```bash
-TAG=0.9.24
+TAG=0.9.25
gh attestation verify "oci://ghcr.io/codeswhat/portwing:${TAG}" \
--repo CodesWhat/portwing
@@ -99,7 +99,7 @@ The `checksums.txt.bundle` file (also a release asset) contains the cosign
keyless signature over `checksums.txt`:
```bash
-TAG=0.9.24
+TAG=0.9.25
cosign verify-blob \
--certificate-identity="https://github.com/CodesWhat/portwing/.github/workflows/release.yml@refs/tags/v${TAG}" \
@@ -123,7 +123,7 @@ On macOS, use `shasum -a 256 --check --ignore-missing checksums.txt`.
```bash
# Verify a specific downloaded archive
-gh attestation verify portwing_0.9.24_linux_amd64.tar.gz \
+gh attestation verify portwing_0.9.25_linux_amd64.tar.gz \
--repo CodesWhat/portwing
```
@@ -133,12 +133,12 @@ gh attestation verify portwing_0.9.24_linux_amd64.tar.gz \
Each release archive has a corresponding [CycloneDX](https://cyclonedx.org/)
SBOM named by appending `.cyclonedx.json` to the complete archive filename. For
-example, the Linux amd64 pair for v0.9.24 is
-`portwing_0.9.24_linux_amd64.tar.gz` and
-`portwing_0.9.24_linux_amd64.tar.gz.cyclonedx.json`.
+example, the Linux amd64 pair for v0.9.25 is
+`portwing_0.9.25_linux_amd64.tar.gz` and
+`portwing_0.9.25_linux_amd64.tar.gz.cyclonedx.json`.
```bash
-VERSION=0.9.24
+VERSION=0.9.25
RELEASE_TAG="v${VERSION}"
ARCHIVE="portwing_${VERSION}_linux_amd64.tar.gz"
SBOM="${ARCHIVE}.cyclonedx.json"
@@ -181,7 +181,7 @@ Buildx OCI SBOM attestation because they also include OS packages.
| Container image | GitHub attestation | `gh attestation verify oci://ghcr.io/codeswhat/portwing:TAG` |
| `checksums.txt` | cosign blob | `cosign verify-blob ... --bundle checksums.txt.bundle checksums.txt` |
| Binary archive | SHA-256 checksum | `sha256sum --check --ignore-missing checksums.txt` |
-| Binary archive | GitHub attestation | `gh attestation verify portwing_0.9.24_linux_amd64.tar.gz --repo CodesWhat/portwing` |
+| Binary archive | GitHub attestation | `gh attestation verify portwing_0.9.25_linux_amd64.tar.gz --repo CodesWhat/portwing` |
| Per-archive SBOM | Signed checksum + GitHub attestation | `cosign verify-blob ... checksums.txt` and `gh attestation verify .cyclonedx.json ...` |
| Container SBOM | OCI SBOM attestation | Inspect the attestation attached to `ghcr.io/codeswhat/portwing:TAG` with an OCI/SBOM-aware client |
diff --git a/docs/content/docs/meta.json b/docs/content/docs/meta.json
index 95c250aa..408459a5 100644
--- a/docs/content/docs/meta.json
+++ b/docs/content/docs/meta.json
@@ -1,22 +1 @@
-{
- "title": "Portwing",
- "pages": [
- "index",
- "getting-started",
- "installation",
- "connection-modes",
- "authentication",
- "configuration",
- "stability-policy",
- "security-model",
- "drydock-integration",
- "standalone-mode",
- "mcp-server",
- "observability",
- "audit-logging",
- "api-reference",
- "verification",
- "competitive-landscape",
- "migrating-from-watchtower"
- ]
-}
+{"pages":["current"]}
diff --git a/docs/source.config.ts b/docs/source.config.ts
index 89050993..ff7e73db 100644
--- a/docs/source.config.ts
+++ b/docs/source.config.ts
@@ -1,7 +1,17 @@
import { defineConfig, defineDocs } from "fumadocs-mdx/config";
+import { remarkVersionedLinks } from "./src/lib/remark-versioned-links.ts";
export const docs = defineDocs({
dir: "content/docs",
});
-export default defineConfig();
+// Global, not on the collection: a collection's own `mdxOptions` is used as-is,
+// without the Fumadocs preset, which silently drops syntax highlighting and the
+// table of contents. The global option layers this plugin on top of the preset.
+// Archived vX.Y/ pages keep their release-time links; this scopes them to the
+// archive at build time and does nothing for current/.
+export default defineConfig({
+ mdxOptions: {
+ remarkPlugins: [remarkVersionedLinks],
+ },
+});
diff --git a/docs/src/app/[[...slug]]/page.tsx b/docs/src/app/[[...slug]]/page.tsx
index 89417287..22a4c2fb 100644
--- a/docs/src/app/[[...slug]]/page.tsx
+++ b/docs/src/app/[[...slug]]/page.tsx
@@ -1,17 +1,46 @@
+import { Callout } from "fumadocs-ui/components/callout";
import defaultMdxComponents from "fumadocs-ui/mdx";
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from "fumadocs-ui/page";
+import type { Metadata } from "next";
+import Link from "next/link";
import { notFound } from "next/navigation";
+import { archiveVersionOf } from "@/lib/docs-versions";
+import { BASE_URL } from "@/lib/site-config";
import { getDocsPage, source } from "@/lib/source";
+// The same page in the current docs, as the slug list the loader knows it by.
+// An archived page's slugs lead with the version segment, so dropping it gives
+// the slug the page has under current/ (or none, for the version's index).
+function currentSlugs(slugs: string[]): string[] {
+ return slugs.slice(1);
+}
+
+function hrefFor(slugs: string[]): string {
+ return slugs.length === 0 ? "/" : `/${slugs.join("/")}`;
+}
+
export default async function Page(props: { params: Promise<{ slug?: string[] }> }) {
const params = await props.params;
const page = getDocsPage(params.slug);
if (!page) notFound();
const MDX = page.data.body;
+ const version = archiveVersionOf(page.path);
+ const counterpart = version ? currentSlugs(page.slugs) : [];
+ const hasCounterpart = version !== undefined && getDocsPage(counterpart) !== undefined;
return (
+ {version && (
+
+ This page documents Portwing {version}, which is no longer supported. It is kept for
+ reference and is not updated.{" "}
+
+ {hasCounterpart ? "Read this page in the current docs" : "Go to the current docs"}
+
+ .
+
+ )}
{page.data.title}
{page.data.description}
@@ -25,13 +54,29 @@ export function generateStaticParams() {
return source.generateParams();
}
-export async function generateMetadata(props: { params: Promise<{ slug?: string[] }> }) {
+export async function generateMetadata(props: {
+ params: Promise<{ slug?: string[] }>;
+}): Promise {
const params = await props.params;
const page = getDocsPage(params.slug);
if (!page) notFound();
+ const version = archiveVersionOf(page.path);
+ if (version) {
+ // Archives are frozen reference copies: out of search results, but crawlers
+ // may still follow their links on to the current docs.
+ return {
+ title: `${page.data.title} (${version})`,
+ description: page.data.description,
+ robots: { index: false, follow: true },
+ };
+ }
+
return {
title: page.data.title,
description: page.data.description,
+ alternates: {
+ canonical: new URL(`/docs${hrefFor(page.slugs).replace(/^\/$/u, "")}`, BASE_URL).toString(),
+ },
};
}
diff --git a/docs/src/app/layout.tsx b/docs/src/app/layout.tsx
index 1b5d1b9e..42add3bd 100644
--- a/docs/src/app/layout.tsx
+++ b/docs/src/app/layout.tsx
@@ -6,6 +6,7 @@ import { AnalyticsRuntime } from "@/components/analytics-runtime";
import { Footer } from "@/components/footer";
import { SiteBackground } from "@/components/site-background";
import { SiteHeader } from "@/components/site-header";
+import { versionTabsOption } from "@/lib/docs-versions";
import { BASE_URL, SITE_CONFIG } from "@/lib/site-config";
import { source } from "@/lib/source";
import "./globals.css";
@@ -73,6 +74,7 @@ export default function RootLayout({
*/}
, so its directory segment never reaches the URL.
+// Archived versions keep their segment: vX.Y/ serves at /docs/vX.Y/.
+const CURRENT_DIR = "current";
+const ARCHIVE_DIR = /^v\d+\.\d+$/u;
+const CONTENT_MARKER = "/content/docs/";
+
+/**
+ * The archived version a docs source path belongs to ("v0.9"), or undefined
+ * for `current/` and anything else. This is the one place that decides what
+ * counts as an archive: the link scoping plugin and the page renderer both ask
+ * here. Accepts the loader's page path (`v0.9/authentication.mdx`) or an
+ * absolute file path that contains `content/docs/`.
+ */
+export function archiveVersionOf(path: string): string | undefined {
+ const normalized = path.replaceAll("\\", "/");
+ const marker = normalized.lastIndexOf(CONTENT_MARKER);
+ const relative = marker === -1 ? normalized : normalized.slice(marker + CONTENT_MARKER.length);
+ const separator = relative.indexOf("/");
+ if (separator === -1) return undefined;
+ const dir = relative.slice(0, separator);
+ return ARCHIVE_DIR.test(dir) ? dir : undefined;
+}
+
+/**
+ * For the Fumadocs loader's `slugs` option. A page under `current/` gets the
+ * slugs Fumadocs would give it without that directory, so route groups and
+ * encoding behave as they do everywhere else. Anything else returns undefined
+ * and takes the loader's default, which keeps the `vX.Y` segment.
+ *
+ * One default is not carried over: a `current/x.mdx` beside a
+ * `current/x/index.mdx` fails the build as a duplicate slug instead of
+ * serving the second at `/x/index`.
+ */
+export function slugsForPath(path: string): string[] | undefined {
+ const prefix = `${CURRENT_DIR}/`;
+ if (!path.startsWith(prefix)) return undefined;
+ return getSlugs(path.slice(prefix.length));
+}
+
+/**
+ * The version switcher is the sidebar tab dropdown, one tab per root folder.
+ * With a single version it would be a dropdown with nothing to switch to, so
+ * pass `false` to DocsLayout until a second root exists.
+ */
+export function versionTabsOption(tree: Root): undefined | false {
+ const roots = tree.children.filter((node) => node.type === "folder" && node.root);
+ return roots.length > 1 ? undefined : false;
+}
diff --git a/docs/src/lib/remark-versioned-links.ts b/docs/src/lib/remark-versioned-links.ts
new file mode 100644
index 00000000..b5bb1a66
--- /dev/null
+++ b/docs/src/lib/remark-versioned-links.ts
@@ -0,0 +1,149 @@
+import { archiveVersionOf } from "./docs-versions.ts";
+
+// An archived page is a byte copy of the current docs, so it still says
+// [Auth](/authentication). Inside vX.Y/ that has to land on /vX.Y/authentication
+// or a reader of old docs is dropped into the current ones. Rewriting at build
+// time (not in the copy) is what keeps the archive identical to its release tag.
+
+interface EstreeNode {
+ type: string;
+ value?: unknown;
+ raw?: string;
+ expression?: EstreeNode;
+ body?: EstreeNode[];
+ expressions?: EstreeNode[];
+ quasis?: { value: { raw: string; cooked?: string | null } }[];
+}
+
+interface MdastNode {
+ type: string;
+ url?: string;
+ name?: string;
+ value?: unknown;
+ data?: { estree?: EstreeNode | null };
+ attributes?: MdastNode[];
+ children?: MdastNode[];
+}
+
+// Last path segment with a file extension: a static asset, not a docs page.
+const ASSET_PATH = /\.[A-Za-z0-9]+$/u;
+
+/**
+ * Scope one root-relative target to a version. Returns the input unchanged for
+ * anything that is not an unversioned docs page path: external and protocol
+ * links, anchors-only, relative paths, `//host`, assets, targets already
+ * under a vX.Y/ segment, and targets that already carry the /docs basePath.
+ */
+export function scopeLink(url: string, version: string): string {
+ if (!url.startsWith("/") || url.startsWith("//")) return url;
+ const end = url.search(/[?#]/u);
+ const path = end === -1 ? url : url.slice(0, end);
+ const rest = end === -1 ? "" : url.slice(end);
+ // Already carries the site basePath: the router would add /docs again.
+ if (path === "/docs" || path.startsWith("/docs/")) return url;
+ if (ASSET_PATH.test(path)) return url;
+ if (/^\/v\d+\.\d+(?:\/|$)/u.test(path)) return url;
+ return `/${version}${path === "/" ? "" : path}${rest}`;
+}
+
+// The expression of `href={...}` when it is a single string literal or a
+// template literal with no interpolation. Anything dynamic returns undefined.
+function staticExpression(
+ attribute: MdastNode,
+): { read: string; write: (next: string) => void } | undefined {
+ const value = attribute.value as { type?: string; value?: string; data?: MdastNode["data"] };
+ if (value?.type !== "mdxJsxAttributeValueExpression" || typeof value.value !== "string") {
+ return undefined;
+ }
+ const source = value.value;
+ const literal = value.data?.estree?.body?.[0]?.expression;
+ if (literal) {
+ if (value.data?.estree?.body?.length !== 1) return undefined;
+ if (literal.type === "Literal" && typeof literal.value === "string") {
+ return {
+ read: literal.value,
+ write(next) {
+ literal.value = next;
+ literal.raw = JSON.stringify(next);
+ value.value = JSON.stringify(next);
+ },
+ };
+ }
+ if (
+ literal.type === "TemplateLiteral" &&
+ literal.expressions?.length === 0 &&
+ literal.quasis?.length === 1 &&
+ typeof literal.quasis[0].value.cooked === "string"
+ ) {
+ const quasi = literal.quasis[0];
+ return {
+ read: quasi.value.cooked as string,
+ write(next) {
+ quasi.value.raw = next;
+ quasi.value.cooked = next;
+ value.value = `\`${next}\``;
+ },
+ };
+ }
+ return undefined;
+ }
+ // No estree (parser without acorn): accept only the plain literal shapes.
+ const quoted = /^\s*(["'])([^"'\\\n]*)\1\s*$/u.exec(source);
+ if (quoted) {
+ return {
+ read: quoted[2],
+ write(next) {
+ value.value = JSON.stringify(next);
+ },
+ };
+ }
+ const template = /^\s*`([^`\\$]*)`\s*$/u.exec(source);
+ if (template) {
+ return {
+ read: template[1],
+ write(next) {
+ value.value = `\`${next}\``;
+ },
+ };
+ }
+ return undefined;
+}
+
+function visit(node: MdastNode, version: string): void {
+ if ((node.type === "link" || node.type === "definition") && typeof node.url === "string") {
+ node.url = scopeLink(node.url, version);
+ } else if (
+ (node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement") &&
+ node.attributes &&
+ // A native gets no basePath from the router, so a scoped root-relative
+ // href would 404 there just as the unscoped one does. Content tests forbid
+ // them; the plugin leaves them alone.
+ node.name !== "a"
+ ) {
+ for (const attribute of node.attributes) {
+ if (attribute.type !== "mdxJsxAttribute" || attribute.name !== "href") continue;
+ if (typeof attribute.value === "string") {
+ attribute.value = scopeLink(attribute.value, version);
+ continue;
+ }
+ const expression = staticExpression(attribute);
+ if (expression) {
+ const scoped = scopeLink(expression.read, version);
+ if (scoped !== expression.read) expression.write(scoped);
+ }
+ }
+ }
+ for (const child of node.children ?? []) visit(child, version);
+}
+
+/**
+ * Remark plugin for docs/source.config.ts. Pages outside a vX.Y/ directory,
+ * including all of current/, are returned untouched.
+ */
+export function remarkVersionedLinks() {
+ return (tree: MdastNode, file: { path?: string }): void => {
+ const version = file.path ? archiveVersionOf(file.path) : undefined;
+ if (!version) return;
+ visit(tree, version);
+ };
+}
diff --git a/docs/src/lib/source.ts b/docs/src/lib/source.ts
index 5f8906e0..48fa8071 100644
--- a/docs/src/lib/source.ts
+++ b/docs/src/lib/source.ts
@@ -1,9 +1,12 @@
import type { Page } from "fumadocs-core/source";
import { loader } from "fumadocs-core/source";
import { docs } from "../../.source/server";
+import { slugsForPath } from "./docs-versions";
export const source = loader(docs.toFumadocsSource(), {
baseUrl: "/",
+ // content/docs/current/ serves at the site root of /docs, so existing URLs stay put.
+ slugs: (file) => slugsForPath(file.path),
});
type DocsPageData = (typeof docs.docs)[number];
diff --git a/docs/tsconfig.json b/docs/tsconfig.json
index 8bfce950..b9069c23 100644
--- a/docs/tsconfig.json
+++ b/docs/tsconfig.json
@@ -6,6 +6,7 @@
"skipLibCheck": true,
"strict": true,
"noEmit": true,
+ "allowImportingTsExtensions": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
diff --git a/examples/observability/docker-compose.yml b/examples/observability/docker-compose.yml
index 1a44b951..7f7d6578 100644
--- a/examples/observability/docker-compose.yml
+++ b/examples/observability/docker-compose.yml
@@ -7,7 +7,7 @@
name: portwing-observability
x-portwing-runtime: &portwing-runtime
- image: ghcr.io/codeswhat/portwing:0.9.24
+ image: ghcr.io/codeswhat/portwing:0.9.25
restart: unless-stopped
read_only: true
cap_drop: [ALL]
diff --git a/go.mod b/go.mod
index 27befa4d..f75565be 100644
--- a/go.mod
+++ b/go.mod
@@ -2,7 +2,7 @@ module github.com/codeswhat/portwing
go 1.26.0
-toolchain go1.27.1
+toolchain go1.27.2
require (
github.com/google/uuid v1.6.0
diff --git a/lefthook.yml b/lefthook.yml
index 4492e0a6..a4a9074c 100644
--- a/lefthook.yml
+++ b/lefthook.yml
@@ -3,7 +3,8 @@
#
# Pre-push pipeline (piped = sequential, fail-fast):
# 1. Clean-tree check (no stray artifacts before push)
-# 2. Package release contract (docs/config agreement, sub-second)
+# 2. Package release contract (docs/config agreement, sub-second) and the
+# docs archive contract
# 3. GoReleaser snapshot dry-run (full release pipeline, skip publish)
# 4. Pinned golangci-lint through the isolated CI adapter
# 5. Qlty full-repository check
@@ -65,6 +66,15 @@ pre-push:
priority: 1
timeout: 30s
+ # The docs archive contract: archives under docs/content/docs/vX.Y/ must be
+ # byte copies of the docs at their recorded release tag. Git plumbing and
+ # jq only, and a no-op check list while zero archives exist.
+ docs-archive-contract:
+ run: bash scripts/docs-archive-config-test.sh
+ fail_text: "Docs archive contract broken — see the FAIL lines above"
+ priority: 1
+ timeout: 30s
+
# The ARMv7 Compose containerd check's own test (fake go, canned build
# info). Mirrors the "Release Contract" job's second step in ci-verify.yml.
compose-containerd-check:
diff --git a/package-lock.json b/package-lock.json
index bcda785b..27c90916 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,7 +15,11 @@
"chrome-launcher": "1.2.2",
"knip": "6.39.0",
"lighthouse": "13.5.0",
- "turbo": "2.11.6"
+ "remark-mdx": "3.1.1",
+ "remark-parse": "11.0.0",
+ "remark-stringify": "11.0.0",
+ "turbo": "2.11.6",
+ "unified": "11.0.5"
},
"engines": {
"node": ">=24.0.0"
diff --git a/package.json b/package.json
index 2eaffe85..1f9bb2c5 100644
--- a/package.json
+++ b/package.json
@@ -14,18 +14,24 @@
"format": "biome format --write .",
"test:web": "node --test analytics/src/contract.test.ts scripts/*.test.mjs && npm --workspace website run test:headers",
"knip": "knip",
+ "docs:archive": "node scripts/docs-archive-cut.mjs",
+ "docs:export-contract": "node scripts/docs-export-contract.mjs",
"budget:web": "node scripts/page-weight-budget.mjs",
"lighthouse:marketing": "node scripts/lighthouse-budget.mjs lighthouse/marketing.cjs",
"lighthouse:docs": "node scripts/lighthouse-budget.mjs lighthouse/docs.cjs",
"lighthouse": "npm run lighthouse:marketing && npm run lighthouse:docs",
- "check:web": "node scripts/node-version-contract.mjs && npm run test:web && npm run lint && npm run knip && npm run build && npm run budget:web && npm run lighthouse"
+ "check:web": "node scripts/node-version-contract.mjs && npm run test:web && npm run lint && npm run knip && npm run build && npm run docs:export-contract && npm run budget:web && npm run lighthouse"
},
"devDependencies": {
"@biomejs/biome": "2.5.15",
"chrome-launcher": "1.2.2",
"knip": "6.39.0",
"lighthouse": "13.5.0",
- "turbo": "2.11.6"
+ "remark-mdx": "3.1.1",
+ "remark-parse": "11.0.0",
+ "remark-stringify": "11.0.0",
+ "turbo": "2.11.6",
+ "unified": "11.0.5"
},
"overrides": {
"postcss": "^8.5.28",
diff --git a/scripts/cflite-config-test.sh b/scripts/cflite-config-test.sh
index 0eccbe89..a9ddd5cb 100755
--- a/scripts/cflite-config-test.sh
+++ b/scripts/cflite-config-test.sh
@@ -155,7 +155,7 @@ for workflow in "${pr_workflow}" "${batch_workflow}" "${prune_workflow}"; do
fi
# gcr.io and storage.googleapis.com serve the OSS-Fuzz images; the Go proxy
- # and checksum database serve both the modules and the go1.27.1 toolchain
+ # and checksum database serve both the modules and the go1.27.2 toolchain
# that base-builder-go's Go 1.25.0 downloads on first use. Any of the four
# missing is a silent network failure inside a container, so name them.
for endpoint in gcr.io:443 storage.googleapis.com:443 proxy.golang.org:443 sum.golang.org:443 github.com:443; do
diff --git a/scripts/ci/go-lint.sh b/scripts/ci/go-lint.sh
index 1d9e520b..05bf88eb 100755
--- a/scripts/ci/go-lint.sh
+++ b/scripts/ci/go-lint.sh
@@ -14,4 +14,4 @@ export GOLANGCI_LINT_CACHE
trap 'rm -rf "${GOLANGCI_LINT_CACHE}"' EXIT
cd "${module_directory}"
-go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2 run
+go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.14.0 run
diff --git a/scripts/ci/go-release-check.sh b/scripts/ci/go-release-check.sh
index 8e3b9d89..f4e60867 100755
--- a/scripts/ci/go-release-check.sh
+++ b/scripts/ci/go-release-check.sh
@@ -40,6 +40,8 @@ bash scripts/main-is-released-config-test-test.sh
bash scripts/main-is-released-config-test.sh
bash scripts/starchart-config-test-test.sh
bash scripts/starchart-config-test.sh
+bash scripts/docs-archive-config-test-test.sh
+bash scripts/docs-archive-config-test.sh
bash scripts/drydock-compat-script-test.sh
bash scripts/benchstat-gate-script-test.sh
bash scripts/benchstat-walk-baselines-test.sh
diff --git a/scripts/compose-containerd-version-test.sh b/scripts/compose-containerd-version-test.sh
index 16cecbbe..3f4b904b 100755
--- a/scripts/compose-containerd-version-test.sh
+++ b/scripts/compose-containerd-version-test.sh
@@ -40,7 +40,7 @@ printf 'not a real binary\n' >"${binary}"
write_dockerfile() {
cat >"${fixture}/Dockerfile" <"${fixture}/Dockerfile" <<'DOCKERFILE'
-FROM golang:1.27.1-alpine AS compose-builder
+FROM golang:1.27.2-alpine AS compose-builder
# RUN go get github.com/containerd/containerd/v2@v2.3.6
RUN go build ./cmd
DOCKERFILE
@@ -134,7 +134,7 @@ expect_fail "has no github.com/containerd/containerd/v2@vX.Y.Z pin" \
# Nor is one that only appears in a trailing comment on a real instruction.
cat >"${fixture}/Dockerfile" <<'DOCKERFILE'
-FROM golang:1.27.1-alpine AS compose-builder
+FROM golang:1.27.2-alpine AS compose-builder
RUN go build ./cmd # go get github.com/containerd/containerd/v2@v2.3.6
DOCKERFILE
expect_fail "has no github.com/containerd/containerd/v2@vX.Y.Z pin" \
diff --git a/scripts/docs-archive-config-test-test.sh b/scripts/docs-archive-config-test-test.sh
new file mode 100755
index 00000000..256baded
--- /dev/null
+++ b/scripts/docs-archive-config-test-test.sh
@@ -0,0 +1,346 @@
+#!/usr/bin/env bash
+# Self-tests for scripts/docs-archive-config-test.sh.
+#
+# Builds a throwaway repository whose history has two release lines, archives
+# the older one by hand, and proves the valid state passes. Each case then
+# breaks that state in exactly one way, commits it, and asserts the contract
+# fails with the matching message. Because the valid state is asserted to pass
+# first, every failure below can only come from the mutation, never from a
+# fixture that was broken to begin with.
+set -euo pipefail
+
+# A git hook exports GIT_DIR and GIT_INDEX_FILE. Inherited here, they would
+# send every fixture commit and tag below into the real repository.
+# shellcheck disable=SC2046
+unset $(git rev-parse --local-env-vars)
+
+contract="$(pwd)/scripts/docs-archive-config-test.sh"
+fixture="$(mktemp -d "${TMPDIR:-/tmp}/portwing-docs-archive.XXXXXX")"
+trap 'rm -rf "${fixture}"' EXIT
+
+docs="docs/content/docs"
+provenance="docs/content/archive-provenance.json"
+
+fx() {
+ git -C "${fixture}" -c user.email=test@example.com -c user.name=test \
+ -c commit.gpgsign=false -c tag.gpgsign=false "$@"
+}
+
+write_file() {
+ mkdir -p "${fixture}/$(dirname "$1")"
+ printf '%s\n' "$2" >"${fixture}/$1"
+}
+
+commit_all() {
+ fx add -A
+ fx commit -q -m "$1"
+}
+
+write_changelog() {
+ local newest="$1"
+ local previous="$2"
+ printf '# Changelog\n\n## [%s] - 2026-10-08\n\n- newest\n\n## [%s] - 2026-10-07\n\n- previous\n' \
+ "${newest}" "${previous}" >"${fixture}/CHANGELOG.md"
+}
+
+# --- history ---------------------------------------------------------------
+fx init -q -b main
+write_file "${docs}/meta.json" '{"pages":["current"]}'
+write_file "${docs}/current/meta.json" '{"title":"v0.8","root":true}'
+write_file "${docs}/current/index.mdx" 'eight index'
+write_file "${docs}/current/setup.mdx" 'eight setup'
+write_file "${provenance}" '{}'
+write_changelog v0.8.0 v0.7.0
+commit_all "v0.8.0 docs"
+fx tag v0.8.0
+write_file "${docs}/current/setup.mdx" 'eight setup, revised'
+write_changelog v0.8.1 v0.8.0
+commit_all "v0.8.1 docs"
+fx tag v0.8.1
+write_file "${docs}/current/meta.json" '{"title":"v0.9","root":true}'
+write_file "${docs}/current/index.mdx" 'nine index'
+write_changelog v0.9.0 v0.8.1
+commit_all "v0.9.0 docs"
+fx tag v0.9.0
+no_archive_commit="$(fx rev-parse HEAD)"
+
+# --- zero archives ----------------------------------------------------------
+run_contract() {
+ (cd "${fixture}" && bash "${contract}" 2>&1)
+}
+
+expect_pass() {
+ local description="$1"
+ local output
+ local status
+
+ set +e
+ output="$(run_contract)"
+ status=$?
+ set -e
+ if [ "${status}" -ne 0 ]; then
+ echo "FAIL: ${description}" >&2
+ echo "${output}" >&2
+ exit 1
+ fi
+}
+
+expect_failure() {
+ local expected="$1"
+ local description="$2"
+ local output
+ local status
+
+ set +e
+ output="$(run_contract)"
+ status=$?
+ set -e
+ if [ "${status}" -eq 0 ] || ! grep -Fq -- "${expected}" <<<"${output}"; then
+ echo "FAIL: ${description}" >&2
+ echo "expected: ${expected}" >&2
+ echo "${output}" >&2
+ exit 1
+ fi
+}
+
+# v0.9.0 is the newest release and v0.8.1 the previous one, so the line has
+# moved on and v0.8 must already be archived. The contract has to say so.
+expect_failure "the previous release v0.8.1 is on v0.8 but current/ documents v0.9" \
+ "leaving a line without archiving it must fail"
+
+# Same-line previous release: nothing to archive yet, and zero archives is fine.
+write_changelog v0.9.1 v0.9.0
+commit_all "same-line changelog"
+expect_pass "zero archives with the previous release on the current line must pass"
+
+# Today's shape: the line current/ left predates the current/ layout (its last
+# tag holds a flat docs tree), so it cannot be archived and must not be demanded.
+flat_tree="$(fx hash-object -w -t tree /dev/null)"
+flat_commit="$(fx commit-tree "${flat_tree}" -m "flat layout, before current/")"
+fx tag v0.7.0 "${flat_commit}"
+write_changelog v0.9.1 v0.7.0
+commit_all "previous line predates the current/ layout"
+expect_pass "a left-behind line whose last tag has no current/ cannot be archived and must pass"
+fx tag -d v0.7.0 >/dev/null
+fx reset -q --hard "${no_archive_commit}"
+
+# --- the valid archive -------------------------------------------------------
+mkdir -p "${fixture}/${docs}/v0.8"
+fx archive v0.8.1 "${docs}/current" | tar -x -C "${fixture}/${docs}/v0.8" --strip-components=4
+
+v081_commit="$(fx rev-parse v0.8.1)"
+v081_tree="$(fx rev-parse "v0.8.1:${docs}/current")"
+write_file "${provenance}" "$(printf '{"v0.8":{"sourceTag":"v0.8.1","sourceCommit":"%s","sourcePath":"%s/current","sourceTree":"%s"}}' \
+ "${v081_commit}" "${docs}" "${v081_tree}")"
+write_file "${docs}/meta.json" '{"pages":["current","v0.8"]}'
+commit_all "archive v0.8"
+valid_commit="$(fx rev-parse HEAD)"
+
+expect_pass "a complete archive with matching provenance must pass"
+
+reset_to_valid() {
+ fx reset -q --hard "${valid_commit}"
+ fx clean -fdq
+ fx tag -l 'v0.8.*' | while read -r name; do
+ if [ "${name}" != "v0.8.0" ] && [ "${name}" != "v0.8.1" ]; then
+ fx tag -d "${name}" >/dev/null
+ fi
+ done
+ if ! fx rev-parse -q --verify refs/tags/v0.8.1 >/dev/null ||
+ [ "$(fx rev-parse v0.8.1)" != "${v081_commit}" ]; then
+ fx tag -f v0.8.1 "${v081_commit}" >/dev/null
+ fi
+}
+
+# --- (a) bijection -----------------------------------------------------------
+cp -R "${fixture}/${docs}/v0.8" "${fixture}/${docs}/v0.7"
+commit_all "archive dir with no provenance"
+expect_failure "docs/content/docs/v0.7/ has no entry in ${provenance}" \
+ "an archive directory without a provenance entry must fail"
+reset_to_valid
+
+jq '. + {"v0.7": .["v0.8"]}' "${fixture}/${provenance}" >"${fixture}/p.tmp"
+mv "${fixture}/p.tmp" "${fixture}/${provenance}"
+commit_all "provenance with no archive dir"
+expect_failure "lists v0.7 but ${docs}/v0.7/ does not exist" \
+ "a provenance entry without an archive directory must fail"
+reset_to_valid
+
+write_file "${docs}/old/index.mdx" 'stray'
+commit_all "stray docs dir"
+expect_failure "docs/content/docs/old/ is neither current/ nor a vX.Y archive" \
+ "a directory that is neither current nor vX.Y must fail"
+reset_to_valid
+
+# --- (b) tag and commit ------------------------------------------------------
+jq --arg c "$(fx rev-parse v0.8.0)" '.["v0.8"].sourceCommit = $c' "${fixture}/${provenance}" >"${fixture}/p.tmp"
+mv "${fixture}/p.tmp" "${fixture}/${provenance}"
+commit_all "sourceCommit points at another release"
+expect_failure "tag v0.8.1 is ${v081_commit}, but" \
+ "a sourceTag that resolves to a different commit must fail"
+reset_to_valid
+
+jq '.["v0.8"].sourceCommit = "0000000000000000000000000000000000000000"' "${fixture}/${provenance}" >"${fixture}/p.tmp"
+mv "${fixture}/p.tmp" "${fixture}/${provenance}"
+commit_all "sourceCommit absent from the repository"
+expect_failure "sourceCommit 0000000000000000000000000000000000000000 does not exist in this repository" \
+ "a sourceCommit missing from a full clone must fail, not skip"
+reset_to_valid
+
+fx tag -d v0.8.1 >/dev/null
+expect_failure "tag v0.8.1 does not exist" \
+ "a provenance entry whose tag was deleted must fail"
+reset_to_valid
+
+jq '.["v0.8"].sourceTag = "v0.7.0"' "${fixture}/${provenance}" >"${fixture}/p.tmp"
+mv "${fixture}/p.tmp" "${fixture}/${provenance}"
+commit_all "sourceTag on another line"
+expect_failure "sourceTag v0.7.0 must be a vX.Y.Z tag of the v0.8 line" \
+ "a sourceTag from a different line must fail"
+reset_to_valid
+
+jq 'del(.["v0.8"].sourcePath)' "${fixture}/${provenance}" >"${fixture}/p.tmp"
+mv "${fixture}/p.tmp" "${fixture}/${provenance}"
+commit_all "incomplete provenance entry"
+expect_failure "entry v0.8 needs sourceTag, sourceCommit, sourcePath and sourceTree" \
+ "an incomplete provenance entry must fail"
+reset_to_valid
+
+# --- (c) tree equality -------------------------------------------------------
+write_file "${docs}/v0.8/setup.mdx" 'hand edited'
+commit_all "archive edited after the cut"
+expect_failure "an archive must be a byte copy of ${docs}/current at v0.8.1" \
+ "an archive edited after the cut must fail"
+reset_to_valid
+
+# Edit the archive and rewrite sourceTree to match it: the first comparison
+# passes, so only the check against the tree at sourceCommit can catch this.
+write_file "${docs}/v0.8/setup.mdx" 'hand edited'
+fx add -A
+edited_tree="$(fx rev-parse "$(fx write-tree):${docs}/v0.8")"
+jq --arg t "${edited_tree}" '.["v0.8"].sourceTree = $t' "${fixture}/${provenance}" >"${fixture}/p.tmp"
+mv "${fixture}/p.tmp" "${fixture}/${provenance}"
+commit_all "archive and sourceTree edited together"
+expect_failure "at ${v081_commit} is tree '${v081_tree}', not the recorded ${edited_tree}" \
+ "a sourceTree rewritten to match an edited archive must fail"
+reset_to_valid
+
+write_file "${docs}/v0.8/setup.mdx" 'uncommitted edit'
+expect_failure "docs/content/docs/v0.8/ has uncommitted changes" \
+ "an uncommitted edit to an archive must fail"
+reset_to_valid
+
+# --- (d) last tag of the line ------------------------------------------------
+write_file "${docs}/current/index.mdx" 'later'
+commit_all "later commit"
+fx tag v0.8.2
+expect_failure "v0.8.1 is not the last tag of the line (v0.8.2 is)" \
+ "an archive cut from a tag that is not the end of its line must fail"
+reset_to_valid
+
+# --- (e) the current line is not an archive ----------------------------------
+write_file "${docs}/current/meta.json" '{"title":"v0.8","root":true}'
+commit_all "current retitled to the archived line"
+expect_failure "v0.8 is the line current/ documents, so it must not also be an archive" \
+ "archiving the line current/ documents must fail"
+reset_to_valid
+
+write_file "${docs}/current/meta.json" '{"title":"next","root":true}'
+commit_all "current without a line title"
+expect_failure "current/meta.json title must name a release line like v0.9, found 'next'" \
+ "a current/ title that is not a line must fail"
+reset_to_valid
+
+# --- (f) a left-behind line must be archived ---------------------------------
+fx rm -rq "${docs}/v0.8"
+write_file "${provenance}" '{}'
+write_file "${docs}/meta.json" '{"pages":["current"]}'
+commit_all "archive removed after the line moved on"
+expect_failure "the previous release v0.8.1 is on v0.8 but current/ documents v0.9" \
+ "removing the archive of the line the changelog just left must fail"
+reset_to_valid
+
+# At v1.0.1 the previous *heading* is v1.0.0 on the current line, so only the
+# CHANGELOG walk to the newest older line (v0.9) can notice the v0.9 archive was
+# deleted. Build the valid state first so the failure can only come from the
+# deletion.
+write_file "${docs}/current/index.mdx" 'nine index, final'
+write_changelog v0.9.1 v0.9.0
+commit_all "v0.9.1 docs"
+fx tag v0.9.1
+v091_commit="$(fx rev-parse v0.9.1)"
+v091_tree="$(fx rev-parse "v0.9.1:${docs}/current")"
+mkdir -p "${fixture}/${docs}/v0.9"
+fx archive v0.9.1 "${docs}/current" | tar -x -C "${fixture}/${docs}/v0.9" --strip-components=4
+jq --arg c "${v091_commit}" --arg t "${v091_tree}" --arg p "${docs}/current" \
+ '. + {"v0.9":{"sourceTag":"v0.9.1","sourceCommit":$c,"sourcePath":$p,"sourceTree":$t}}' \
+ "${fixture}/${provenance}" >"${fixture}/p.tmp"
+mv "${fixture}/p.tmp" "${fixture}/${provenance}"
+write_file "${docs}/meta.json" '{"pages":["current","v0.9","v0.8"]}'
+write_file "${docs}/current/meta.json" '{"title":"v1.0","root":true}'
+write_file "${docs}/current/index.mdx" 'ten index'
+printf '# Changelog\n\n## [v1.0.1] - 2026-10-10\n\n- a\n\n## [v1.0.0] - 2026-10-09\n\n- b\n\n## [v0.9.1] - 2026-10-08\n\n- c\n' >"${fixture}/CHANGELOG.md"
+commit_all "v1.0 current with the v0.9 archive"
+expect_pass "v1.0.1 with the v0.9 archive present must pass"
+
+fx rm -rq "${docs}/v0.9"
+jq 'del(.["v0.9"])' "${fixture}/${provenance}" >"${fixture}/p.tmp"
+mv "${fixture}/p.tmp" "${fixture}/${provenance}"
+write_file "${docs}/meta.json" '{"pages":["current","v0.8"]}'
+commit_all "v0.9 archive removed at v1.0.1"
+expect_failure "the previous release v0.9.1 is on v0.9 but current/ documents v1.0" \
+ "deleting the archive of the line before the current one must fail even when the previous heading is on the current line"
+fx reset -q --hard "${valid_commit}"
+fx clean -fdq
+fx tag -d v0.9.1 >/dev/null
+
+# --- provenance file ---------------------------------------------------------
+fx rm -q "${provenance}"
+commit_all "provenance removed"
+expect_failure "${provenance} is missing" "a missing provenance file must fail"
+reset_to_valid
+
+write_file "${provenance}" '[]'
+commit_all "provenance is not an object"
+expect_failure "${provenance} must be a JSON object keyed by vX.Y" \
+ "a provenance file that is not an object must fail"
+reset_to_valid
+
+# --- shallow clones ----------------------------------------------------------
+# The reusable CI gates run from depth-1 clones that carry neither the release
+# commit nor its tags. That shape must skip loudly and pass; a broken archive
+# in the same shallow clone must still fail on the checks that need no history.
+shallow="$(mktemp -d "${TMPDIR:-/tmp}/portwing-docs-archive-shallow.XXXXXX")"
+git clone -q --depth 1 "file://${fixture}" "${shallow}/repo" 2>/dev/null
+set +e
+shallow_output="$(cd "${shallow}/repo" && bash "${contract}" 2>&1)"
+shallow_status=$?
+set -e
+if [ "${shallow_status}" -ne 0 ] || ! grep -Fq "SKIP: shallow checkout lacks ${v081_commit}" <<<"${shallow_output}"; then
+ echo "FAIL: a shallow checkout must skip the tag checks with a SKIP line and pass" >&2
+ echo "${shallow_output}" >&2
+ rm -rf "${shallow}"
+ exit 1
+fi
+rm -rf "${shallow}"
+
+write_file "${docs}/v0.8/setup.mdx" 'hand edited'
+commit_all "archive edited after the cut"
+shallow="$(mktemp -d "${TMPDIR:-/tmp}/portwing-docs-archive-shallow.XXXXXX")"
+git clone -q --depth 1 "file://${fixture}" "${shallow}/repo" 2>/dev/null
+set +e
+shallow_output="$(cd "${shallow}/repo" && bash "${contract}" 2>&1)"
+shallow_status=$?
+set -e
+if [ "${shallow_status}" -eq 0 ] || ! grep -Fq "an archive must be a byte copy" <<<"${shallow_output}"; then
+ echo "FAIL: a shallow checkout must still reject an edited archive" >&2
+ echo "${shallow_output}" >&2
+ rm -rf "${shallow}"
+ exit 1
+fi
+rm -rf "${shallow}"
+reset_to_valid
+
+expect_pass "the valid archive must pass again after all mutations are reverted"
+
+echo "Docs archive contract self-tests passed."
diff --git a/scripts/docs-archive-config-test.sh b/scripts/docs-archive-config-test.sh
new file mode 100755
index 00000000..b0ffa607
--- /dev/null
+++ b/scripts/docs-archive-config-test.sh
@@ -0,0 +1,213 @@
+#!/usr/bin/env bash
+# Docs archive contract. Run from the repository root.
+#
+# An archive (docs/content/docs/vX.Y/) is a frozen copy of docs/content/docs/
+# current/ as it was at the last release tag of that line, and
+# docs/content/archive-provenance.json says which. This holds the two together:
+# every archive has an entry, every entry points at a real tag, and the
+# committed archive is that tag's tree, so a hand edit to an archive fails here
+# instead of passing as "documentation". With no archives it checks only the
+# rules that need none, which is why it is safe to run before the first cut.
+set -euo pipefail
+
+failures=0
+docs_root="docs/content/docs"
+provenance_file="docs/content/archive-provenance.json"
+line_regex='^v[0-9]+\.[0-9]+$'
+tag_regex='^v[0-9]+\.[0-9]+\.[0-9]+$'
+
+fail() {
+ echo "FAIL: $1" >&2
+ failures=$((failures + 1))
+}
+
+if ! command -v jq >/dev/null 2>&1; then
+ echo "FAIL: jq is required to read ${provenance_file}" >&2
+ exit 1
+fi
+if [ ! -f "${provenance_file}" ]; then
+ echo "FAIL: ${provenance_file} is missing; it holds {} until the first archive is cut" >&2
+ exit 1
+fi
+if ! jq -e 'type == "object"' "${provenance_file}" >/dev/null 2>&1; then
+ echo "FAIL: ${provenance_file} must be a JSON object keyed by vX.Y" >&2
+ exit 1
+fi
+
+shallow="false"
+if [ "$(git rev-parse --is-shallow-repository 2>/dev/null)" = "true" ]; then
+ shallow="true"
+fi
+
+# (a) Archive directories and provenance entries are a bijection.
+archive_dirs=""
+for dir in "${docs_root}"/*/; do
+ [ -d "${dir}" ] || continue
+ name="$(basename "${dir}")"
+ if [ "${name}" = "current" ]; then
+ continue
+ fi
+ if ! grep -Eq "${line_regex}" <<<"${name}"; then
+ fail "${docs_root}/${name}/ is neither current/ nor a vX.Y archive"
+ continue
+ fi
+ archive_dirs="${archive_dirs} ${name}"
+done
+provenance_lines="$(jq -r 'keys[]' "${provenance_file}")"
+
+for name in ${archive_dirs}; do
+ if ! grep -Fxq -- "${name}" <<<"${provenance_lines}"; then
+ fail "${docs_root}/${name}/ has no entry in ${provenance_file}; cut archives with npm run docs:archive"
+ fi
+done
+for name in ${provenance_lines}; do
+ if ! grep -Eq "${line_regex}" <<<"${name}"; then
+ fail "${provenance_file} key ${name} must look like vX.Y"
+ continue
+ fi
+ case " ${archive_dirs} " in
+ *" ${name} "*) ;;
+ *) fail "${provenance_file} lists ${name} but ${docs_root}/${name}/ does not exist" ;;
+ esac
+done
+
+# (e) The line current/ documents is not also archived.
+current_line="$(jq -r '.title // empty' "${docs_root}/current/meta.json")"
+if ! grep -Eq "${line_regex}" <<<"${current_line}"; then
+ fail "${docs_root}/current/meta.json title must name a release line like v0.9, found '${current_line}'"
+ current_line=""
+elif [ -d "${docs_root}/${current_line}" ] || grep -Fxq -- "${current_line}" <<<"${provenance_lines}"; then
+ fail "${current_line} is the line current/ documents, so it must not also be an archive"
+fi
+
+# (b)-(d) Per archive: tag, commit and tree agree, and the tag ends its line.
+for name in ${provenance_lines}; do
+ case " ${archive_dirs} " in
+ *" ${name} "*) ;;
+ *) continue ;;
+ esac
+ source_tag="$(jq -r --arg v "${name}" '.[$v].sourceTag // empty' "${provenance_file}")"
+ source_commit="$(jq -r --arg v "${name}" '.[$v].sourceCommit // empty' "${provenance_file}")"
+ source_path="$(jq -r --arg v "${name}" '.[$v].sourcePath // empty' "${provenance_file}")"
+ source_tree="$(jq -r --arg v "${name}" '.[$v].sourceTree // empty' "${provenance_file}")"
+
+ if [ -z "${source_tag}" ] || [ -z "${source_commit}" ] || [ -z "${source_path}" ] || [ -z "${source_tree}" ]; then
+ fail "${provenance_file} entry ${name} needs sourceTag, sourceCommit, sourcePath and sourceTree"
+ continue
+ fi
+ if ! grep -Eq "${tag_regex}" <<<"${source_tag}" || [ "${source_tag%.*}" != "${name}" ]; then
+ fail "${name}: sourceTag ${source_tag} must be a vX.Y.Z tag of the ${name} line"
+ continue
+ fi
+
+ # (c, first half) The committed archive is the recorded tree. HEAD, not the
+ # working tree: CI and release-cut see only what was committed.
+ if [ -n "$(git status --porcelain --untracked-files=all -- "${docs_root}/${name}")" ]; then
+ fail "${docs_root}/${name}/ has uncommitted changes; archives are immutable once committed"
+ fi
+ archive_tree="$(git rev-parse --verify --quiet "HEAD:${docs_root}/${name}" || true)"
+ if [ -z "${archive_tree}" ]; then
+ fail "${docs_root}/${name}/ is not committed at HEAD"
+ elif [ "${archive_tree}" != "${source_tree}" ]; then
+ fail "${docs_root}/${name}/ is tree ${archive_tree} at HEAD but ${provenance_file} records ${source_tree}; an archive must be a byte copy of ${source_path} at ${source_tag}"
+ fi
+
+ # The rest needs the release commit and its tags. A shallow clone has
+ # neither, and the org's reusable gates run from one, so say so and skip
+ # rather than fail every PR or pass silently. A full clone missing them is
+ # a real failure.
+ if ! git cat-file -e "${source_commit}^{commit}" 2>/dev/null; then
+ if [ "${shallow}" = "true" ]; then
+ echo "SKIP: shallow checkout lacks ${source_commit}, so the ${name} tag and source-tree checks did not run here; they run in Release Contract, release-cut, and pre-push" >&2
+ else
+ fail "${name}: sourceCommit ${source_commit} does not exist in this repository"
+ fi
+ continue
+ fi
+
+ # (b) sourceTag resolves to sourceCommit.
+ resolved="$(git rev-parse --verify --quiet "refs/tags/${source_tag}^{commit}" || true)"
+ if [ -z "${resolved}" ]; then
+ if [ "${shallow}" = "true" ]; then
+ echo "SKIP: shallow checkout lacks tag ${source_tag}, so its checks did not run here" >&2
+ else
+ fail "${name}: tag ${source_tag} does not exist"
+ fi
+ elif [ "${resolved}" != "${source_commit}" ]; then
+ fail "${name}: tag ${source_tag} is ${resolved}, but ${provenance_file} records ${source_commit}"
+ fi
+
+ # (c, second half) sourceTree is what sourcePath really held at sourceCommit.
+ commit_tree="$(git rev-parse --verify --quiet "${source_commit}:${source_path}" || true)"
+ if [ "${commit_tree}" != "${source_tree}" ]; then
+ fail "${name}: ${source_path} at ${source_commit} is tree '${commit_tree}', not the recorded ${source_tree}"
+ fi
+
+ # (d) sourceTag is the last tag of its line.
+ if [ -n "${resolved}" ]; then
+ line_tags="$(git tag -l "${name}.*" | grep -E "${tag_regex}" || true)"
+ last_tag="$(printf '%s\n' "${line_tags}" | sort -t. -k3,3n | tail -n 1)"
+ if [ "${last_tag}" != "${source_tag}" ]; then
+ fail "${name}: ${source_tag} is not the last tag of the line (${last_tag} is); archive the docs the line ended with"
+ fi
+ fi
+done
+
+# (f) Once the line has moved on, the line it left must already be archived.
+# "The line it left" is the newest line in the CHANGELOG that is numerically
+# older than current/'s, found by walking release headings (newest first) past
+# every patch release on current's own line. Looking only at the previous
+# heading would let v1.0.1 (previous release v1.0.0, same line) drop the v0.9
+# archive unnoticed.
+#
+# Floor: docs/content/docs/current/ did not exist before the docs archive work,
+# and the cut script refuses tags without it. A line whose last tag lacks that
+# path can never be archived, so the rule does not ask for one. Today
+# (current = v0.9) the line it left is v0.8, which predates that layout.
+line_key() {
+ local major="${1#v}"
+ major="${major%%.*}"
+ local minor="${1##*.}"
+ echo $((10#${major} * 100000 + 10#${minor}))
+}
+
+release_versions="$(grep -E '^## \[v[0-9]+\.[0-9]+\.[0-9]+\] - [0-9]{4}-[0-9]{2}-[0-9]{2}$' CHANGELOG.md |
+ sed -E 's/^## \[(v[0-9]+\.[0-9]+\.[0-9]+)\].*/\1/' || true)"
+if [ -z "${release_versions}" ]; then
+ fail "could not read the previous release from CHANGELOG.md"
+elif [ -n "${current_line}" ]; then
+ current_key="$(line_key "${current_line}")"
+ previous_version=""
+ previous_line=""
+ for version in ${release_versions}; do
+ if [ "$(line_key "${version%.*}")" -lt "${current_key}" ]; then
+ previous_version="${version}"
+ previous_line="${version%.*}"
+ break
+ fi
+ done
+ if [ -n "${previous_line}" ]; then
+ case " ${archive_dirs} " in
+ *" ${previous_line} "*) ;;
+ *)
+ previous_last_tag="$(git tag -l "${previous_line}.*" | grep -E "${tag_regex}" | sort -t. -k3,3n | tail -n 1 || true)"
+ if [ -z "${previous_last_tag}" ]; then
+ if [ "${shallow}" = "true" ]; then
+ echo "SKIP: shallow checkout lacks the ${previous_line} tags, so the archive-required check did not run here" >&2
+ else
+ fail "the previous release ${previous_version} is on ${previous_line} but no ${previous_line}.* tag exists to archive from"
+ fi
+ elif git rev-parse --verify --quiet "${previous_last_tag}:${docs_root}/current" >/dev/null; then
+ fail "the previous release ${previous_version} is on ${previous_line} but current/ documents ${current_line}; archive ${previous_line} from its last tag with npm run docs:archive"
+ fi
+ ;;
+ esac
+ fi
+fi
+
+if [ "${failures}" -ne 0 ]; then
+ echo "${failures} docs archive contract check(s) failed" >&2
+ exit 1
+fi
+
+echo "Docs archive contract checks passed."
diff --git a/scripts/docs-archive-cut.mjs b/scripts/docs-archive-cut.mjs
new file mode 100644
index 00000000..0c029aef
--- /dev/null
+++ b/scripts/docs-archive-cut.mjs
@@ -0,0 +1,242 @@
+// Cut a docs archive: copy docs/content/docs/current/ as it was at a release
+// tag into docs/content/docs/vX.Y/, record where it came from, and list it in
+// the root meta.json. Usage: npm run docs:archive -- vX.Y.Z
+//
+// The copy comes from the tag's git tree, never the working tree, so the
+// archive is the docs that shipped with that release rather than whatever the
+// checkout holds today. scripts/docs-archive-config-test.sh holds the result to
+// that promise afterwards.
+import { execFileSync } from "node:child_process";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import process from "node:process";
+import { fileURLToPath } from "node:url";
+
+const SOURCE_PATH = "docs/content/docs/current";
+const DOCS_DIR = "docs/content/docs";
+const PROVENANCE = "docs/content/archive-provenance.json";
+const TAG = /^v(\d+)\.(\d+)\.(\d+)$/u;
+const LINE = /^v(\d+)\.(\d+)$/u;
+
+class CutError extends Error {}
+
+function fail(message) {
+ throw new CutError(message);
+}
+
+function parseArgs(argv) {
+ let root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+ let tag;
+ for (let i = 0; i < argv.length; i += 1) {
+ if (argv[i] === "--root") {
+ i += 1;
+ if (!argv[i]) fail("--root needs a directory");
+ root = path.resolve(argv[i]);
+ } else if (argv[i].startsWith("-")) {
+ fail(`unknown option ${argv[i]}`);
+ } else if (tag === undefined) {
+ tag = argv[i];
+ } else {
+ fail(`unexpected argument ${argv[i]}`);
+ }
+ }
+ if (tag === undefined) fail("usage: npm run docs:archive -- vX.Y.Z");
+ return { root, tag };
+}
+
+function readJson(file) {
+ try {
+ return JSON.parse(fs.readFileSync(file, "utf8"));
+ } catch (error) {
+ fail(`cannot read ${file} as JSON: ${error.message}`);
+ }
+}
+
+function readJsonObject(file, label) {
+ const value = readJson(file);
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
+ fail(`${label} must be a JSON object`);
+ }
+ return value;
+}
+
+function writeJson(file, value) {
+ fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
+}
+
+// Newest line first, by numeric major then minor.
+function byLineDescending(a, b) {
+ const [, aMajor, aMinor] = LINE.exec(a);
+ const [, bMajor, bMinor] = LINE.exec(b);
+ return Number(bMajor) - Number(aMajor) || Number(bMinor) - Number(aMinor);
+}
+
+function cut({ root, tag }) {
+ // -C does not override GIT_DIR or GIT_INDEX_FILE. Run from a hook or a
+ // wrapper that exports them, git would read some other repository than root.
+ const env = Object.fromEntries(
+ Object.entries(process.env).filter(([name]) => !name.startsWith("GIT_")),
+ );
+ const git = (...args) =>
+ execFileSync("git", ["-C", root, ...args], {
+ encoding: "utf8",
+ env,
+ maxBuffer: 64 << 20,
+ }).trim();
+ const gitBuffer = (...args) =>
+ execFileSync("git", ["-C", root, ...args], { env, maxBuffer: 64 << 20 });
+
+ const match = TAG.exec(tag);
+ if (!match) fail(`${tag} is not a release tag; expected vX.Y.Z`);
+ const [, major, minor, patch] = match;
+ const line = `v${major}.${minor}`;
+
+ let commit;
+ try {
+ commit = git("rev-parse", "--verify", "--quiet", `refs/tags/${tag}^{commit}`);
+ } catch {
+ fail(`tag ${tag} does not exist here; fetch tags first`);
+ }
+
+ // The archive must be the end of its line, or the next patch's docs changes
+ // would be missing from "the docs for v0.9".
+ const laterPatches = git("tag", "-l", `v${major}.${minor}.*`)
+ .split("\n")
+ .filter((name) => TAG.test(name))
+ .filter((name) => Number(TAG.exec(name)[3]) > Number(patch));
+ if (laterPatches.length > 0) {
+ const newest = laterPatches
+ .sort((a, b) => Number(TAG.exec(a)[3]) - Number(TAG.exec(b)[3]))
+ .at(-1);
+ fail(`${tag} is not the last tag of ${line}; cut from ${newest} instead`);
+ }
+
+ const docsRoot = path.join(root, DOCS_DIR);
+ const archiveDir = path.join(docsRoot, line);
+ if (fs.existsSync(archiveDir))
+ fail(`${DOCS_DIR}/${line}/ already exists; archives are immutable`);
+
+ const currentMeta = readJsonObject(
+ path.join(docsRoot, "current", "meta.json"),
+ "current/meta.json",
+ );
+ if (!LINE.test(currentMeta.title ?? "")) {
+ fail(
+ `${DOCS_DIR}/current/meta.json title must be a line like v0.9, found ${currentMeta.title}`,
+ );
+ }
+ if (currentMeta.title === line) {
+ fail(`${line} is the line current/ documents; archive it only after current/ moves on`);
+ }
+
+ const provenancePath = path.join(root, PROVENANCE);
+ let provenanceBefore;
+ try {
+ provenanceBefore = fs.readFileSync(provenancePath, "utf8");
+ } catch (error) {
+ if (error.code !== "ENOENT") throw error;
+ }
+ const provenance =
+ provenanceBefore === undefined ? {} : readJsonObject(provenancePath, PROVENANCE);
+ if (provenance[line]) fail(`${PROVENANCE} already has an entry for ${line}`);
+
+ // Every input is read and validated before the first write, so a bad file
+ // cannot leave a half-done cut that the retry then refuses as immutable.
+ const rootMetaPath = path.join(docsRoot, "meta.json");
+ const rootMetaBefore = fs.readFileSync(rootMetaPath, "utf8");
+ const rootMeta = readJsonObject(rootMetaPath, `${DOCS_DIR}/meta.json`);
+ const archives = [
+ ...fs
+ .readdirSync(docsRoot, { withFileTypes: true })
+ .filter((entry) => entry.isDirectory() && LINE.test(entry.name))
+ .map((entry) => entry.name),
+ line,
+ ].sort(byLineDescending);
+
+ // `current/` only exists in tags cut after docs moved under it. Older tags
+ // have a flat docs/content/docs, which is not byte-identical to any archive
+ // layout, so they are refused rather than converted.
+ let sourceTree;
+ try {
+ sourceTree = git("rev-parse", "--verify", "--quiet", `${commit}:${SOURCE_PATH}`);
+ } catch {
+ fail(`${tag} has no ${SOURCE_PATH}; tags from before the current/ move cannot be archived`);
+ }
+ const entries = gitBuffer("ls-tree", "-r", "-z", commit, "--", SOURCE_PATH)
+ .toString("utf8")
+ .split("\0")
+ .filter(Boolean)
+ .map((row) => {
+ const tab = row.indexOf("\t");
+ const [mode, type, sha] = row.slice(0, tab).split(" ");
+ return { mode, type, sha, rel: row.slice(tab + 1 + SOURCE_PATH.length + 1) };
+ });
+ const odd = entries.find((entry) => entry.type !== "blob" || entry.mode !== "100644");
+ if (odd) fail(`${tag}:${SOURCE_PATH}/${odd.rel} is not a regular file (${odd.mode} ${odd.type})`);
+ if (entries.length === 0) fail(`${tag}:${SOURCE_PATH} is empty`);
+
+ const staging = fs.mkdtempSync(path.join(os.tmpdir(), "docs-archive-"));
+ try {
+ for (const entry of entries) {
+ const target = path.join(staging, entry.rel);
+ fs.mkdirSync(path.dirname(target), { recursive: true });
+ fs.writeFileSync(target, gitBuffer("cat-file", "blob", entry.sha));
+ }
+ // Byte-identity check against the tree itself, without git's line-ending
+ // or attribute filters, so a checkout setting cannot alter the copy.
+ for (const entry of entries) {
+ const written = execFileSync(
+ "git",
+ ["hash-object", "--no-filters", path.join(staging, entry.rel)],
+ { encoding: "utf8", env },
+ ).trim();
+ if (written !== entry.sha) fail(`${entry.rel} did not copy byte-for-byte`);
+ }
+ // Writes start here. If any step fails, undo only what this run did.
+ try {
+ fs.cpSync(staging, archiveDir, { recursive: true, errorOnExist: true, force: false });
+
+ provenance[line] = {
+ sourceTag: tag,
+ sourceCommit: commit,
+ sourcePath: SOURCE_PATH,
+ sourceTree,
+ };
+ const sortedProvenance = Object.fromEntries(
+ Object.keys(provenance)
+ .sort(byLineDescending)
+ .map((key) => [key, provenance[key]]),
+ );
+ fs.mkdirSync(path.dirname(provenancePath), { recursive: true });
+ writeJson(provenancePath, sortedProvenance);
+
+ // current first, then archives newest first. Other keys in meta.json stay.
+ writeJson(rootMetaPath, { ...rootMeta, pages: ["current", ...archives] });
+ } catch (error) {
+ // The cut refused up front if this directory existed, so whatever is
+ // there now came from this run.
+ fs.rmSync(archiveDir, { recursive: true, force: true });
+ if (provenanceBefore === undefined) fs.rmSync(provenancePath, { force: true });
+ else fs.writeFileSync(provenancePath, provenanceBefore);
+ fs.writeFileSync(rootMetaPath, rootMetaBefore);
+ throw error;
+ }
+ } finally {
+ fs.rmSync(staging, { recursive: true, force: true });
+ }
+
+ return { line, commit, sourceTree, files: entries.length };
+}
+
+try {
+ const result = cut(parseArgs(process.argv.slice(2)));
+ console.log(
+ `Archived ${result.files} files from ${result.commit.slice(0, 12)} as ${DOCS_DIR}/${result.line}/ (tree ${result.sourceTree}).`,
+ );
+ console.log(`Next: review the diff and commit docs/content/docs and ${PROVENANCE} together.`);
+} catch (error) {
+ if (!(error instanceof CutError)) throw error;
+ console.error(`FAIL: ${error.message}`);
+ process.exit(1);
+}
diff --git a/scripts/docs-archive-cut.test.mjs b/scripts/docs-archive-cut.test.mjs
new file mode 100644
index 00000000..10d1d749
--- /dev/null
+++ b/scripts/docs-archive-cut.test.mjs
@@ -0,0 +1,232 @@
+import assert from "node:assert/strict";
+import { execFileSync, spawnSync } from "node:child_process";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import test from "node:test";
+
+const SCRIPT = path.resolve(import.meta.dirname, "docs-archive-cut.mjs");
+const DOCS = "docs/content/docs";
+
+// A git hook exports GIT_DIR and GIT_INDEX_FILE, and lefthook runs this suite
+// from pre-push. With those inherited, every git call below would land in the
+// real repository instead of the fixture, so no GIT_* variable is passed on.
+const ENV = Object.fromEntries(
+ Object.entries(process.env).filter(([name]) => !name.startsWith("GIT_")),
+);
+
+function git(cwd, ...args) {
+ return execFileSync(
+ "git",
+ [
+ "-c",
+ "user.email=test@example.com",
+ "-c",
+ "user.name=test",
+ "-c",
+ "commit.gpgsign=false",
+ "-c",
+ "tag.gpgsign=false",
+ ...args,
+ ],
+ { cwd, encoding: "utf8", env: ENV },
+ ).trim();
+}
+
+function write(root, rel, content) {
+ const file = path.join(root, rel);
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(file, content);
+}
+
+function setCurrent(root, line, pages) {
+ fs.rmSync(path.join(root, DOCS, "current"), { recursive: true, force: true });
+ write(root, `${DOCS}/current/meta.json`, JSON.stringify({ title: line, root: true }));
+ for (const [name, body] of Object.entries(pages)) {
+ write(root, `${DOCS}/current/${name}`, body);
+ }
+}
+
+function commit(root, message, tag) {
+ git(root, "add", "-A");
+ git(root, "commit", "-q", "-m", message);
+ if (tag) git(root, "tag", tag);
+}
+
+// History: a flat-layout tag, v0.7.0, v0.8.0 and v0.8.1 (whose docs differ from
+// the working tree), then HEAD documenting v0.9 with a v0.9.0 tag.
+function fixture(t) {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "docs-archive-cut-test-"));
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
+ git(root, "init", "-q", "-b", "main");
+ // Never commit or tag unless git is really working inside the fixture.
+ assert.equal(
+ fs.realpathSync(git(root, "rev-parse", "--absolute-git-dir")),
+ path.join(fs.realpathSync(root), ".git"),
+ );
+ write(root, `${DOCS}/meta.json`, JSON.stringify({ pages: ["current"] }));
+ write(root, `${DOCS}/index.mdx`, "flat layout\n");
+ commit(root, "flat docs", "v0.1.0");
+ fs.rmSync(path.join(root, DOCS, "index.mdx"));
+ setCurrent(root, "v0.7", { "index.mdx": "seven index\n", "setup.mdx": "seven setup\n" });
+ commit(root, "v0.7 docs", "v0.7.0");
+ setCurrent(root, "v0.8", {
+ "index.mdx": "eight index\n",
+ "guide/deep.mdx": "eight deep\n",
+ "zero.mdx": "",
+ });
+ commit(root, "v0.8 docs", "v0.8.0");
+ write(root, `${DOCS}/current/index.mdx`, "eight index, revised\n");
+ commit(root, "v0.8.1 docs", "v0.8.1");
+ setCurrent(root, "v0.9", { "index.mdx": "nine index\n" });
+ commit(root, "v0.9 docs", "v0.9.0");
+ return root;
+}
+
+function cut(root, ...args) {
+ const result = spawnSync("node", [SCRIPT, "--root", root, ...args], {
+ encoding: "utf8",
+ env: ENV,
+ });
+ return { status: result.status, out: `${result.stdout}${result.stderr}` };
+}
+
+function snapshot(root) {
+ return git(root, "status", "--porcelain", "--untracked-files=all");
+}
+
+function readJson(root, rel) {
+ return JSON.parse(fs.readFileSync(path.join(root, rel), "utf8"));
+}
+
+test("cuts the archive from the tag, not the working tree", (t) => {
+ const root = fixture(t);
+ const result = cut(root, "v0.8.1");
+ assert.equal(result.status, 0, result.out);
+
+ const archive = path.join(root, DOCS, "v0.8");
+ assert.equal(fs.readFileSync(path.join(archive, "index.mdx"), "utf8"), "eight index, revised\n");
+ assert.equal(fs.readFileSync(path.join(archive, "guide/deep.mdx"), "utf8"), "eight deep\n");
+ assert.equal(fs.readFileSync(path.join(archive, "zero.mdx"), "utf8"), "");
+ assert.equal(readJson(root, `${DOCS}/v0.8/meta.json`).title, "v0.8");
+ // The working tree's current/ was not touched.
+ assert.equal(
+ fs.readFileSync(path.join(root, DOCS, "current", "index.mdx"), "utf8"),
+ "nine index\n",
+ );
+
+ const commitSha = git(root, "rev-list", "-n", "1", "v0.8.1");
+ assert.deepEqual(readJson(root, "docs/content/archive-provenance.json"), {
+ "v0.8": {
+ sourceTag: "v0.8.1",
+ sourceCommit: commitSha,
+ sourcePath: `${DOCS}/current`,
+ sourceTree: git(root, "rev-parse", `v0.8.1:${DOCS}/current`),
+ },
+ });
+ assert.deepEqual(readJson(root, `${DOCS}/meta.json`), { pages: ["current", "v0.8"] });
+
+ // Provenance is a tree-hash equality: the committed archive is the tag's tree.
+ git(root, "add", "-A");
+ const staged = git(root, "write-tree");
+ assert.equal(
+ git(root, "rev-parse", `${staged}:${DOCS}/v0.8`),
+ git(root, "rev-parse", `v0.8.1:${DOCS}/current`),
+ );
+});
+
+test("lists archives newest line first, current first of all", (t) => {
+ const root = fixture(t);
+ assert.equal(cut(root, "v0.7.0").status, 0);
+ assert.equal(cut(root, "v0.8.1").status, 0);
+ assert.deepEqual(readJson(root, `${DOCS}/meta.json`).pages, ["current", "v0.8", "v0.7"]);
+ assert.deepEqual(Object.keys(readJson(root, "docs/content/archive-provenance.json")), [
+ "v0.8",
+ "v0.7",
+ ]);
+});
+
+test("refuses without changing anything", (t) => {
+ const root = fixture(t);
+ const before = snapshot(root);
+ const cases = [
+ [["0.8.1"], "not a release tag"],
+ [["v0.8"], "not a release tag"],
+ [["v0.8.1-rc.1"], "not a release tag"],
+ [["v0.8.9"], "does not exist"],
+ [["v0.8.0"], "not the last tag of v0.8"],
+ [["v0.9.0"], "line current/ documents"],
+ [["v0.1.0"], "tags from before the current/ move"],
+ [[], "usage"],
+ [["v0.8.1", "extra"], "unexpected argument"],
+ ];
+ for (const [args, message] of cases) {
+ const result = cut(root, ...args);
+ assert.notEqual(result.status, 0, `${args.join(" ")} should fail`);
+ assert.ok(result.out.includes(message), `${args.join(" ")}: ${result.out}`);
+ assert.equal(snapshot(root), before, `${args.join(" ")} left changes behind`);
+ }
+});
+
+test("refuses to overwrite an existing archive or provenance entry", (t) => {
+ const root = fixture(t);
+ assert.equal(cut(root, "v0.8.1").status, 0);
+ const before = snapshot(root);
+ const again = cut(root, "v0.8.1");
+ assert.notEqual(again.status, 0);
+ assert.ok(again.out.includes("already exists"), again.out);
+
+ fs.rmSync(path.join(root, DOCS, "v0.8"), { recursive: true });
+ const orphan = cut(root, "v0.8.1");
+ assert.notEqual(orphan.status, 0);
+ assert.ok(orphan.out.includes("already has an entry"), orphan.out);
+ assert.notEqual(before, "");
+});
+
+test("an invalid root meta.json leaves no archive and no provenance change", (t) => {
+ const root = fixture(t);
+ write(root, "docs/content/archive-provenance.json", "{}\n");
+ fs.writeFileSync(path.join(root, DOCS, "meta.json"), "{ not json");
+ const before = snapshot(root);
+ const provenanceBefore = fs.readFileSync(
+ path.join(root, "docs/content/archive-provenance.json"),
+ "utf8",
+ );
+
+ const failed = cut(root, "v0.8.1");
+ assert.notEqual(failed.status, 0);
+ assert.ok(failed.out.includes("meta.json"), failed.out);
+ assert.equal(fs.existsSync(path.join(root, DOCS, "v0.8")), false);
+ assert.equal(
+ fs.readFileSync(path.join(root, "docs/content/archive-provenance.json"), "utf8"),
+ provenanceBefore,
+ );
+ assert.equal(snapshot(root), before);
+
+ // Fixing the file makes the retry succeed instead of hitting "immutable".
+ write(root, `${DOCS}/meta.json`, JSON.stringify({ pages: ["current"] }));
+ const retry = cut(root, "v0.8.1");
+ assert.equal(retry.status, 0, retry.out);
+});
+
+test("a write that fails midway undoes what the run created", (t) => {
+ const root = fixture(t);
+ // A directory where the provenance file must go makes the second write fail
+ // after the archive has already been copied.
+ fs.mkdirSync(path.join(root, "docs/content/archive-provenance.json"), { recursive: true });
+ const failed = cut(root, "v0.8.1");
+ assert.notEqual(failed.status, 0);
+ assert.equal(fs.existsSync(path.join(root, DOCS, "v0.8")), false);
+ assert.deepEqual(readJson(root, `${DOCS}/meta.json`), { pages: ["current"] });
+});
+
+test("the replacement tag is the numerically newest patch", (t) => {
+ const root = fixture(t);
+ for (const name of ["v0.8.9", "v0.8.10"]) {
+ git(root, "commit", "-q", "--allow-empty", "-m", name);
+ git(root, "tag", name);
+ }
+ const result = cut(root, "v0.8.1");
+ assert.notEqual(result.status, 0);
+ assert.ok(result.out.includes("cut from v0.8.10 instead"), result.out);
+});
diff --git a/scripts/docs-export-contract.mjs b/scripts/docs-export-contract.mjs
new file mode 100644
index 00000000..cb4b53e6
--- /dev/null
+++ b/scripts/docs-export-contract.mjs
@@ -0,0 +1,94 @@
+// After `next build` in docs/, the static export is what ships at /docs. Moving
+// the content under content/docs/current/ must not leak that directory into
+// the output, every current page must still land at its unversioned URL, and
+// each archived content/docs/vX.Y/ must export under its own segment: pages at
+// vX.Y/.html and the version's index at vX.Y.html (the segment is the
+// slug of its index page, so Next names it like a page, not a directory index).
+import fs from "node:fs";
+import path from "node:path";
+import process from "node:process";
+import { fileURLToPath } from "node:url";
+
+const CURRENT_DIR = "current";
+const VERSION_DIR = /^v\d+\.\d+$/u;
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const DOCS_CONTENT = path.join(ROOT, "docs", "content", "docs");
+const OUT = path.join(ROOT, "docs", "out");
+
+// Mirrors slugsForPath in docs/src/lib/docs-versions.ts, which the loader uses.
+// Kept separate on purpose: the contract should catch the loader drifting.
+function slugsFor(file) {
+ const segments = file
+ .replace(/\.mdx?$/u, "")
+ .split(path.sep)
+ .filter((segment) => !/^\(.+\)$/u.test(segment));
+ if (segments.at(-1) === "index") segments.pop();
+ return segments;
+}
+
+function listFiles(dir) {
+ return fs
+ .readdirSync(dir, { recursive: true, withFileTypes: true })
+ .filter((entry) => entry.isFile())
+ .map((entry) => path.relative(dir, path.join(entry.parentPath, entry.name)));
+}
+
+// Pages Next emits for itself, not from content.
+const FRAMEWORK_PAGES = new Set(["404.html", "_not-found.html"]);
+
+// Every page file of one version dir, keyed by the HTML file it must export as.
+// `prefix` is the URL segment the version serves under: none for current/.
+function expectedPages(dir, prefix) {
+ const expected = new Map();
+ const pages = listFiles(path.join(DOCS_CONTENT, dir)).filter((file) => /\.mdx?$/u.test(file));
+ for (const page of pages) {
+ const slugs = [...prefix, ...slugsFor(page)];
+ expected.set(slugs.length === 0 ? "index.html" : `${slugs.join("/")}.html`, `${dir}/${page}`);
+ }
+ return expected;
+}
+
+const problems = [];
+
+if (!fs.existsSync(OUT)) {
+ problems.push(`${path.relative(ROOT, OUT)} is missing; run the docs build first`);
+} else {
+ const expected = expectedPages(CURRENT_DIR, []);
+ if (!expected.has("index.html")) {
+ problems.push(`${CURRENT_DIR}/index.mdx is missing; /docs would have no landing page`);
+ }
+ const archives = fs
+ .readdirSync(DOCS_CONTENT, { withFileTypes: true })
+ .filter((entry) => entry.isDirectory() && VERSION_DIR.test(entry.name))
+ .map((entry) => entry.name);
+ for (const version of archives) {
+ for (const [html, page] of expectedPages(version, [version])) expected.set(html, page);
+ if (!fs.existsSync(path.join(DOCS_CONTENT, version, "index.mdx"))) {
+ problems.push(`${version}/index.mdx is missing; /docs/${version} would have no landing page`);
+ }
+ }
+ const exported = new Set(
+ listFiles(OUT)
+ .map((file) => file.split(path.sep).join("/"))
+ .filter((file) => file.endsWith(".html") && !file.startsWith("_next/"))
+ .filter((file) => !FRAMEWORK_PAGES.has(file)),
+ );
+ for (const [html, page] of expected) {
+ if (!exported.has(html)) {
+ problems.push(`${page} must export as docs/out/${html}`);
+ }
+ }
+ for (const html of exported) {
+ if (!expected.has(html)) {
+ problems.push(
+ `docs/out/${html} has no page in ${CURRENT_DIR}/ or an archive; a directory leaked into the URL, a page went missing from the source, or an archive was removed without a clean build`,
+ );
+ }
+ }
+}
+
+if (problems.length > 0) {
+ for (const problem of problems) console.error(`FAIL: ${problem}`);
+ process.exit(1);
+}
+console.log("Docs export contract checks passed.");
diff --git a/scripts/docs-export-contract.test.mjs b/scripts/docs-export-contract.test.mjs
new file mode 100644
index 00000000..9e78b961
--- /dev/null
+++ b/scripts/docs-export-contract.test.mjs
@@ -0,0 +1,103 @@
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import test, { after } from "node:test";
+
+const SCRIPT = path.resolve(import.meta.dirname, "docs-export-contract.mjs");
+
+// The contract finds the repo root from its own location, so a copy placed in a
+// temp tree checks that tree instead of the real one.
+const made = [];
+after(() => {
+ for (const root of made) fs.rmSync(root, { recursive: true, force: true });
+});
+
+function tree({ content, out }) {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "docs-export-contract-test-"));
+ made.push(root);
+ fs.mkdirSync(path.join(root, "scripts"));
+ fs.copyFileSync(SCRIPT, path.join(root, "scripts", "docs-export-contract.mjs"));
+ for (const file of content) touch(root, `docs/content/docs/${file}`);
+ for (const file of out) touch(root, `docs/out/${file}`);
+ return root;
+}
+
+function touch(root, rel) {
+ const file = path.join(root, rel);
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(file, "");
+}
+
+function run(root) {
+ const result = spawnSync("node", [path.join(root, "scripts", "docs-export-contract.mjs")], {
+ encoding: "utf8",
+ });
+ return { status: result.status, out: `${result.stdout}${result.stderr}` };
+}
+
+const CURRENT = ["current/index.mdx", "current/auth.mdx", "current/guide/deep.mdx"];
+const CURRENT_OUT = ["index.html", "auth.html", "guide/deep.html", "404.html", "_next/x.html"];
+const ARCHIVE = ["v0.8/index.mdx", "v0.8/auth.mdx", "v0.8/guide/deep.mdx"];
+const ARCHIVE_OUT = ["v0.8.html", "v0.8/auth.html", "v0.8/guide/deep.html"];
+
+test("passes with zero archives and with a complete archive", () => {
+ assert.equal(run(tree({ content: CURRENT, out: CURRENT_OUT })).status, 0);
+ const withArchive = run(
+ tree({ content: [...CURRENT, ...ARCHIVE], out: [...CURRENT_OUT, ...ARCHIVE_OUT] }),
+ );
+ assert.equal(withArchive.status, 0, withArchive.out);
+});
+
+test("an archive's index exports as vX.Y.html, not vX.Y/index.html", () => {
+ const result = run(
+ tree({
+ content: [...CURRENT, ...ARCHIVE],
+ out: [...CURRENT_OUT, "v0.8/index.html", "v0.8/auth.html", "v0.8/guide/deep.html"],
+ }),
+ );
+ assert.notEqual(result.status, 0);
+ assert.ok(result.out.includes("v0.8/index.mdx must export as docs/out/v0.8.html"), result.out);
+ assert.ok(result.out.includes("docs/out/v0.8/index.html has no page"), result.out);
+});
+
+test("fails when an archive page is missing from the export", () => {
+ const result = run(
+ tree({
+ content: [...CURRENT, ...ARCHIVE],
+ out: [...CURRENT_OUT, "v0.8.html", "v0.8/guide/deep.html"],
+ }),
+ );
+ assert.notEqual(result.status, 0);
+ assert.ok(
+ result.out.includes("v0.8/auth.mdx must export as docs/out/v0.8/auth.html"),
+ result.out,
+ );
+});
+
+test("fails on exported versions that have no archive", () => {
+ const result = run(
+ tree({ content: CURRENT, out: [...CURRENT_OUT, "v0.8.html", "v0.8/auth.html"] }),
+ );
+ assert.notEqual(result.status, 0);
+ assert.ok(result.out.includes("docs/out/v0.8.html has no page"), result.out);
+ assert.ok(result.out.includes("docs/out/v0.8/auth.html has no page"), result.out);
+});
+
+test("fails when an archive has no index page", () => {
+ const result = run(
+ tree({
+ content: [...CURRENT, "v0.8/auth.mdx"],
+ out: [...CURRENT_OUT, "v0.8/auth.html"],
+ }),
+ );
+ assert.notEqual(result.status, 0);
+ assert.ok(result.out.includes("v0.8/index.mdx is missing"), result.out);
+});
+
+test("still catches a leaked current/ directory", () => {
+ const result = run(tree({ content: CURRENT, out: [...CURRENT_OUT, "current/auth.html"] }));
+ assert.notEqual(result.status, 0);
+ assert.ok(result.out.includes("docs/out/current/auth.html has no page"), result.out);
+});
diff --git a/scripts/docs-versions.test.mjs b/scripts/docs-versions.test.mjs
new file mode 100644
index 00000000..cc4d0def
--- /dev/null
+++ b/scripts/docs-versions.test.mjs
@@ -0,0 +1,175 @@
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import path from "node:path";
+import test from "node:test";
+
+const ROOT = path.resolve(import.meta.dirname, "..");
+const DOCS_ROOT = path.join(ROOT, "docs", "content", "docs");
+const VERSION_DIR = /^v\d+\.\d+$/u;
+const PAGE_FILE = /\.mdx?$/u;
+
+function read(relativePath) {
+ return fs.readFileSync(path.join(ROOT, relativePath), "utf8");
+}
+
+// Version directories under content/docs: `current` plus archived vX.Y trees.
+function versionDirs() {
+ return fs
+ .readdirSync(DOCS_ROOT, { withFileTypes: true })
+ .filter((entry) => entry.isDirectory())
+ .map((entry) => entry.name);
+}
+
+// Page slugs of one version dir, relative to that dir ("index" is the root page).
+function pageSlugs(dir) {
+ return fs
+ .readdirSync(path.join(DOCS_ROOT, dir), { recursive: true, withFileTypes: true })
+ .filter((entry) => entry.isFile() && PAGE_FILE.test(entry.name))
+ .map((entry) =>
+ path
+ .relative(path.join(DOCS_ROOT, dir), path.join(entry.parentPath, entry.name))
+ .replace(PAGE_FILE, "")
+ .split(path.sep)
+ .join("/"),
+ )
+ .sort();
+}
+
+// The public URL path of every current page, without the /docs prefix.
+function currentPublicSlugs() {
+ return pageSlugs("current")
+ .filter((slug) => slug !== "index")
+ .sort();
+}
+
+function stringsIn(source, startPattern) {
+ const start = source.search(startPattern);
+ assert.notEqual(start, -1, `${startPattern} not found`);
+ const end = source.indexOf("]", start);
+ return [...source.slice(start, end).matchAll(/"([^"]*)"/gu)].map((m) => m[1]);
+}
+
+test("every docs page lives in current/ or a vX.Y/ directory", () => {
+ const entries = fs.readdirSync(DOCS_ROOT, { withFileTypes: true });
+ for (const entry of entries) {
+ if (entry.isDirectory()) {
+ assert.ok(
+ entry.name === "current" || VERSION_DIR.test(entry.name),
+ `${entry.name}/ must be current or vX.Y`,
+ );
+ } else {
+ assert.equal(entry.name, "meta.json", `${entry.name} sits outside current/ and vX.Y/`);
+ }
+ }
+ assert.ok(versionDirs().includes("current"));
+ const rootMeta = JSON.parse(fs.readFileSync(path.join(DOCS_ROOT, "meta.json"), "utf8"));
+ assert.ok(rootMeta.pages.includes("current"));
+ for (const dir of versionDirs()) {
+ const meta = JSON.parse(fs.readFileSync(path.join(DOCS_ROOT, dir, "meta.json"), "utf8"));
+ assert.equal(meta.root, true, `${dir}/meta.json must be a root folder`);
+ assert.equal(typeof meta.title, "string", `${dir}/meta.json needs a title`);
+ assert.ok(rootMeta.pages.includes(dir), `content/docs/meta.json must list ${dir}`);
+ }
+});
+
+test("the sitemap lists exactly the current docs pages", () => {
+ const sitemap = read("website/src/app/sitemap.ts");
+ const listed = stringsIn(sitemap, /const DOCS_SLUGS = \[/u).sort();
+ assert.deepEqual(listed, currentPublicSlugs());
+});
+
+test("the analytics docs allowlist is exactly the current docs pages", () => {
+ const contract = read("analytics/src/contract.ts");
+ const listed = stringsIn(contract, /const DOCS_PATHS = new Set\(\[/u).sort();
+ const expected = ["/docs", ...currentPublicSlugs().map((slug) => `/docs/${slug}`)].sort();
+ assert.deepEqual(listed, expected);
+});
+
+// Source of one page with fenced and inline code removed, so examples that
+// show a link do not count as one.
+function proseOf(file) {
+ return fs
+ .readFileSync(file, "utf8")
+ .replace(/```[\s\S]*?```/gu, "")
+ .replace(/`[^`\n]*`/gu, "");
+}
+
+function allPages() {
+ const pages = [];
+ for (const dir of versionDirs()) {
+ for (const slug of pageSlugs(dir)) {
+ for (const extension of ["mdx", "md"]) {
+ const file = path.join(DOCS_ROOT, dir, `${slug}.${extension}`);
+ if (fs.existsSync(file)) pages.push({ label: `${dir}/${slug}`, file });
+ }
+ }
+ }
+ return pages;
+}
+
+test("root-relative links resolve to a page in the version they name or sit in", () => {
+ // Archived pages keep the unscoped links they had at their release tag;
+ // docs/src/lib/remark-versioned-links.ts rewrites them to /vX.Y/... at build.
+ // So an unversioned link is checked against its own dir's page set. A link
+ // that names another version on purpose (/v0.8/x inside v0.9/) is checked
+ // against that version's page set, and fails if that archive does not exist.
+ const dirs = new Set(versionDirs());
+ const sets = new Map();
+ const setFor = (dir) => {
+ if (!sets.has(dir)) sets.set(dir, new Set(pageSlugs(dir)));
+ return sets.get(dir);
+ };
+ const dead = [];
+ for (const dir of dirs) {
+ for (const slug of setFor(dir)) {
+ const file = path.join(DOCS_ROOT, dir, `${slug}.mdx`);
+ const source = (fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "").replace(
+ /```[\s\S]*?```/gu,
+ "",
+ );
+ for (const match of source.matchAll(/\]\((\/[^)\s]*)\)/gu)) {
+ const [target] = match[1].split(/[?#]/u);
+ let resolvedDir = dir;
+ let relative = target.slice(1);
+ const named = /^(v\d+\.\d+)(?:\/|$)/u.exec(relative);
+ if (named) {
+ resolvedDir = named[1];
+ relative = relative.slice(named[0].length);
+ if (!dirs.has(resolvedDir)) {
+ dead.push(
+ `${dir}/${slug}: ${match[1]} (no ${resolvedDir}/ archive exists to link into)`,
+ );
+ continue;
+ }
+ }
+ const resolved = relative.replace(/\/$/u, "") || "index";
+ if (!setFor(resolvedDir).has(resolved)) {
+ dead.push(`${dir}/${slug}: ${match[1]}`);
+ }
+ }
+ }
+ }
+ assert.deepEqual(dead, []);
+});
+
+test("no page uses a raw with a root-relative href", () => {
+ // A native gets no basePath from Next, so /authentication points at the
+ // site root, not /docs/authentication, and the archive link scoping skips
+ // native anchors. Use a markdown link: [text](/page).
+ const raw = / ]*\bhref\s*=\s*(?:\{\s*)?["'`]\//u;
+ const found = allPages()
+ .filter(({ file }) => raw.test(proseOf(file)))
+ .map(({ label }) => label);
+ assert.deepEqual(found, [], "use a markdown link instead of a raw ");
+});
+
+test("no page links to /docs/... directly", () => {
+ // Next adds the /docs basePath to a root-relative link, so /docs/x becomes
+ // /docs/docs/x. Archive scoping also leaves /docs/... untouched. Write the
+ // target without the prefix: [text](/page).
+ const prefixed = /(?:\]\(|\bhref\s*=\s*(?:\{\s*)?["'`])\/docs(?=[/#?)"'`\s]|$)/u;
+ const found = allPages()
+ .filter(({ file }) => prefixed.test(proseOf(file)))
+ .map(({ label }) => label);
+ assert.deepEqual(found, [], "drop the /docs prefix from root-relative links");
+});
diff --git a/scripts/lighthouse-budget.mjs b/scripts/lighthouse-budget.mjs
index 12ef0832..c190df17 100644
--- a/scripts/lighthouse-budget.mjs
+++ b/scripts/lighthouse-budget.mjs
@@ -150,6 +150,9 @@ export function isChromeConnectionError(error) {
/unable to connect to chrome/i.test(message) ||
/failed to fetch browser websocket url/i.test(message) ||
/chrome (has crashed|failed to launch|connection)/i.test(message) ||
+ // chrome-launcher's launch timeout: Chrome started and never wrote its
+ // DevTools port to chrome-err.log.
+ /waiting for dynamic debugging port/i.test(message) ||
/(target|session) closed/i.test(message)
) {
return true;
diff --git a/scripts/lighthouse-budget.test.mjs b/scripts/lighthouse-budget.test.mjs
index 8e12634d..c98950de 100644
--- a/scripts/lighthouse-budget.test.mjs
+++ b/scripts/lighthouse-budget.test.mjs
@@ -120,6 +120,9 @@ test("isChromeConnectionError matches Chrome/connection failures and not budget
);
assert.ok(isChromeConnectionError(new Error("read ECONNRESET")));
assert.ok(isChromeConnectionError(new Error("socket hang up")));
+ assert.ok(
+ isChromeConnectionError(new Error("waiting for dynamic debugging port in chrome-err.log")),
+ );
assert.ok(
!isChromeConnectionError(new Error("marketing performance budget exceeded: 0.5 < 0.66")),
);
diff --git a/scripts/package-release-config-test-test.sh b/scripts/package-release-config-test-test.sh
index 0909429f..5a8a6edd 100644
--- a/scripts/package-release-config-test-test.sh
+++ b/scripts/package-release-config-test-test.sh
@@ -37,10 +37,10 @@ cp scripts/verify-scanner-exclusions.sh "${fixture}/scripts/"
cp .grype.yaml Dockerfile.armv7 Dockerfile.release "${fixture}/"
cp api/openapi.yaml "${fixture}/api/"
cp examples/observability/docker-compose.yml "${fixture}/examples/observability/"
-cp docs/content/docs/api-reference.mdx "${fixture}/docs/content/docs/"
-cp docs/content/docs/standalone-mode.mdx "${fixture}/docs/content/docs/"
-cp docs/content/docs/observability.mdx "${fixture}/docs/content/docs/"
-cp docs/content/docs/security-model.mdx "${fixture}/docs/content/docs/"
+cp docs/content/docs/current/api-reference.mdx "${fixture}/docs/content/docs/current/"
+cp docs/content/docs/current/standalone-mode.mdx "${fixture}/docs/content/docs/current/"
+cp docs/content/docs/current/observability.mdx "${fixture}/docs/content/docs/current/"
+cp docs/content/docs/current/security-model.mdx "${fixture}/docs/content/docs/current/"
mkdir -p "${fixture}/scripts/ci"
cp scripts/ci/go-release-check.sh "${fixture}/scripts/ci/"
cp scripts/ci/verify-compose-containerd.sh "${fixture}/scripts/ci/"
@@ -370,10 +370,19 @@ expect_stale_release_example_failure() {
expect_stale_release_example_failure "api/openapi.yaml" $' agentVersion:\n example: "0.0.1"'
expect_stale_release_example_failure "api/openapi.yaml" ' data: {"type":"dd:ack","data":{"version":"0.0.1"}}'
-expect_stale_release_example_failure "docs/content/docs/api-reference.mdx" '{"version":"0.0.1"}'
-expect_stale_release_example_failure "docs/content/docs/standalone-mode.mdx" '{"agentVersion":"0.0.1"}'
-expect_stale_release_example_failure "docs/content/docs/observability.mdx" 'portwing_build_info{version="0.0.1"} 1'
-expect_stale_release_example_failure "docs/content/docs/security-model.mdx" 'VERSION=0.0.1'
+expect_stale_release_example_failure "docs/content/docs/current/api-reference.mdx" '{"version":"0.0.1"}'
+expect_stale_release_example_failure "docs/content/docs/current/standalone-mode.mdx" '{"agentVersion":"0.0.1"}'
+expect_stale_release_example_failure "docs/content/docs/current/observability.mdx" 'portwing_build_info{version="0.0.1"} 1'
+expect_stale_release_example_failure "docs/content/docs/current/security-model.mdx" 'VERSION=0.0.1'
+
+docs_meta_backup="${fixture}/docs/content/docs/current/meta.json.title-backup"
+cp "${fixture}/docs/content/docs/current/meta.json" "${docs_meta_backup}"
+sed -i.bak 's/"title": "v[0-9.]*"/"title": "v0.0"/' "${fixture}/docs/content/docs/current/meta.json"
+rm -f "${fixture}/docs/content/docs/current/meta.json.bak"
+expect_release_contract_failure \
+ "FAIL: docs/content/docs/current/meta.json title must be v" \
+ "the package release contract must reject a docs version title that does not match the release minor"
+mv "${docs_meta_backup}" "${fixture}/docs/content/docs/current/meta.json"
gitleaksignore_backup="${fixture}/.gitleaksignore.backup"
cp "${fixture}/.gitleaksignore" "${gitleaksignore_backup}"
@@ -415,6 +424,27 @@ expect_release_contract_failure \
"the package release contract must reject the 'since v' phrase outside README.md"
mv "${releasing_since_backup}" "${fixture}/RELEASING.md"
+# Docs archives (docs/content/docs/vX.Y/) are frozen copies of older releases
+# and name old versions on purpose, so the stale-version grep skips them. The
+# same literal in current/ is still a forgotten bump and must still fail. The
+# files are staged because git grep only searches tracked paths.
+archive_stale_dir="${fixture}/docs/content/docs/v0.0"
+mkdir -p "${archive_stale_dir}"
+printf 'Installed with VERSION=%s in this release.\n' "${previous_version}" >"${archive_stale_dir}/index.mdx"
+git -C "${fixture}" add docs/content/docs/v0.0
+if ! (cd "${fixture}" && bash scripts/package-release-config-test.sh >/dev/null); then
+ echo "FAIL: a previous-version literal inside docs/content/docs/vX.Y/ must not fail the stale-version check" >&2
+ exit 1
+fi
+current_stale_file="${fixture}/docs/content/docs/current/stale-probe.mdx"
+cp "${archive_stale_dir}/index.mdx" "${current_stale_file}"
+git -C "${fixture}" add docs/content/docs/current/stale-probe.mdx
+expect_release_contract_failure \
+ "FAIL: stale references to v${previous_version} remain outside the changelog" \
+ "the package release contract must still reject a previous-version literal in docs/content/docs/current/"
+git -C "${fixture}" rm -rfq --cached docs/content/docs/v0.0 docs/content/docs/current/stale-probe.mdx
+rm -rf "${archive_stale_dir}" "${current_stale_file}"
+
# The CHANGELOG-vs-tag guard only sees tags the checkout actually has, so
# exercising it needs a real tag in the fixture repo. It guards against
# portwing#283: a prep PR renamed the v0.9.12 heading to v0.9.13 instead of
diff --git a/scripts/package-release-config-test.sh b/scripts/package-release-config-test.sh
index 9cbdb0c9..06b7800a 100755
--- a/scripts/package-release-config-test.sh
+++ b/scripts/package-release-config-test.sh
@@ -356,8 +356,17 @@ if [ -n "${stale_readme_examples}" ]; then
fi
require_text "website/src/lib/site-config.ts" "version: \"${release_version}\"" "website metadata must identify the current release"
require_text "website/src/components/get-started.tsx" "portwing_${release_version}_linux_amd64.deb" "website package examples must use the current release"
-require_text "docs/content/docs/installation.mdx" "VERSION=${release_version}" "installation examples must use the current release"
-require_text "docs/content/docs/verification.mdx" "TAG=${release_version}" "verification examples must use the current release"
+require_text "docs/content/docs/current/installation.mdx" "VERSION=${release_version}" "installation examples must use the current release"
+require_text "docs/content/docs/current/verification.mdx" "TAG=${release_version}" "verification examples must use the current release"
+# The docs version switcher labels the live tree with the minor it documents.
+# A minor release that forgets to bump the title would publish v0.9 docs
+# under a v0.10 binary.
+docs_version_title="$(sed -nE 's/^[[:space:]]*"title":[[:space:]]*"([^"]*)".*/\1/p' docs/content/docs/current/meta.json | sed -n '1p')"
+expected_docs_version_title="v$(printf '%s' "${release_version}" | sed -E 's/^([0-9]+\.[0-9]+)\..*/\1/')"
+if [ "${release_version}" != "unresolved" ] && [ "${docs_version_title}" != "${expected_docs_version_title}" ]; then
+ echo "FAIL: docs/content/docs/current/meta.json title must be ${expected_docs_version_title} for release ${release_version} (found: ${docs_version_title:-none})" >&2
+ failures=$((failures + 1))
+fi
require_text "website/public/llms.txt" "Portwing v${release_version} is" "agent discovery metadata must identify the current release"
require_text "ROADMAP.md" "currently \`v${release_version}\`" "the roadmap must identify the current release"
require_text "COMPATIBILITY.md" "v${release_version} (latest release) / \`main\`" "the compatibility matrix must identify the current release"
@@ -381,19 +390,19 @@ validate_current_release_example_values "api/openapi.yaml" \
require_current_release_examples "api/openapi.yaml" \
'"type":"dd:ack".*"version":"[0-9]+\.[0-9]+\.[0-9]+"' \
"OpenAPI Drydock handshake examples must identify the current release"
-require_current_release_examples "docs/content/docs/api-reference.mdx" \
+require_current_release_examples "docs/content/docs/current/api-reference.mdx" \
'"(agentVersion|version)"[[:space:]]*:[[:space:]]*"[0-9]+\.[0-9]+\.[0-9]+"' \
"API reference response examples must identify the current release"
-require_current_release_examples "docs/content/docs/standalone-mode.mdx" \
+require_current_release_examples "docs/content/docs/current/standalone-mode.mdx" \
'"(agentVersion|version)"[[:space:]]*:[[:space:]]*"[0-9]+\.[0-9]+\.[0-9]+"' \
"standalone-mode response examples must identify the current release"
-require_current_release_examples "docs/content/docs/observability.mdx" \
+require_current_release_examples "docs/content/docs/current/observability.mdx" \
'"version"[[:space:]]*:[[:space:]]*"[0-9]+\.[0-9]+\.[0-9]+"' \
"observability response examples must identify the current release"
-require_current_release_examples "docs/content/docs/observability.mdx" \
+require_current_release_examples "docs/content/docs/current/observability.mdx" \
'portwing_build_info\{version="[0-9]+\.[0-9]+\.[0-9]+"\}' \
"observability build metadata examples must identify the current release"
-require_current_release_examples "docs/content/docs/security-model.mdx" \
+require_current_release_examples "docs/content/docs/current/security-model.mdx" \
'^VERSION=[0-9]+\.[0-9]+\.[0-9]+$' \
"security verification examples must identify the current release"
@@ -410,9 +419,16 @@ require_current_release_examples "docs/content/docs/security-model.mdx" \
# an rpm example, a doc's attestation command - still fails; a broader
# file-wide exemption would let a stale version hide behind an unrelated
# "since v" line anywhere in the repo.
+#
+# The other exemption is docs/content/docs/vX.Y/: an archive is a frozen copy of
+# the docs at an older release, so it names that release's versions on purpose,
+# and editing them would break its byte-for-byte tie to the tag. The archive
+# contract (docs-archive-config-test.sh) polices those files instead. current/
+# gets no such pass.
stale_previous_version_matches="$(git grep -n -F -- "$previous_version" -- \
'*.md' '*.mdx' '*.ts' '*.tsx' '*.yaml' '*.yml' '*.txt' \
':(exclude)CHANGELOG.md' \
+ ':(exclude)docs/content/docs/v[0-9]*' \
':(exclude)scripts/package-release-config-test.sh' || true)"
if [ -n "${stale_previous_version_matches}" ]; then
stale_previous_version_matches="$(awk -v exempt="since v${previous_version}" '
@@ -802,7 +818,7 @@ if cmp -s docs/assets/star-history.svg docs/assets/star-history-dark.svg; then
failures=$((failures + 1))
fi
-require_file "docs/content/docs/installation.mdx" "the documentation site must include native installation guidance"
+require_file "docs/content/docs/current/installation.mdx" "the documentation site must include native installation guidance"
require_file "NOTICE" "project identity and copyright must live outside the standard license text"
require_first_line "LICENSE" " GNU AFFERO GENERAL PUBLIC LICENSE" "LICENSE must begin with the canonical AGPL-3.0 text"
require_sha256 "LICENSE" "8486a10c4393cee1c25392769ddd3b2d6c242d6ec7928e1414efff7dfb2f07ef" "LICENSE must match GitHub's canonical AGPL-3.0 template byte for byte"
@@ -812,20 +828,20 @@ if [ -f "NOTICE" ]; then
require_text "NOTICE" "Portwing - Lightweight Remote Docker Agent" "NOTICE must preserve the project identity"
require_text "NOTICE" "Copyright (C) 2026 CodesWhat" "NOTICE must preserve the project copyright"
fi
-if [ -f "docs/content/docs/installation.mdx" ]; then
- require_text "docs/content/docs/installation.mdx" "brew install --cask codeswhat/tap/portwing" "Homebrew installation must be documented"
- require_text "docs/content/docs/installation.mdx" "apt install" "deb installation must be documented"
- require_text "docs/content/docs/installation.mdx" "rpm --install" "rpm installation must be documented"
- require_text "docs/content/docs/installation.mdx" "Upgrade" "package upgrades must be documented"
- require_text "docs/content/docs/installation.mdx" "Uninstall" "package removal must be documented"
- require_text "docs/content/docs/installation.mdx" "portwing.service" "service expectations must be documented"
- require_text "docs/content/docs/installation.mdx" "checksums.txt" "artifact verification must be linked to package installation"
+if [ -f "docs/content/docs/current/installation.mdx" ]; then
+ require_text "docs/content/docs/current/installation.mdx" "brew install --cask codeswhat/tap/portwing" "Homebrew installation must be documented"
+ require_text "docs/content/docs/current/installation.mdx" "apt install" "deb installation must be documented"
+ require_text "docs/content/docs/current/installation.mdx" "rpm --install" "rpm installation must be documented"
+ require_text "docs/content/docs/current/installation.mdx" "Upgrade" "package upgrades must be documented"
+ require_text "docs/content/docs/current/installation.mdx" "Uninstall" "package removal must be documented"
+ require_text "docs/content/docs/current/installation.mdx" "portwing.service" "service expectations must be documented"
+ require_text "docs/content/docs/current/installation.mdx" "checksums.txt" "artifact verification must be linked to package installation"
fi
require_text "README.md" "brew install --cask codeswhat/tap/portwing" "the repository landing page must advertise Homebrew installation"
require_text "README.md" "/docs/installation" "the repository landing page must link the full package guide"
# shellcheck disable=SC2016 # The documented shell command expands VERSION.
-require_text "docs/content/docs/verification.mdx" 'release.yml@refs/tags/v${VERSION}' "public verification instructions must bind signatures to the selected tag"
+require_text "docs/content/docs/current/verification.mdx" 'release.yml@refs/tags/v${VERSION}' "public verification instructions must bind signatures to the selected tag"
require_text "RELEASING.md" "HOMEBREW_TAP_TOKEN" "maintainer release docs must name the tap publishing credential"
require_text "RELEASING.md" "verify-native-packages" "maintainer release docs must describe the native package gate"
require_text "website/src/components/get-started.tsx" "codeswhat/tap/portwing" "the website must advertise the Homebrew cask"
diff --git a/scripts/remark-versioned-links.test.mjs b/scripts/remark-versioned-links.test.mjs
new file mode 100644
index 00000000..9ab89112
--- /dev/null
+++ b/scripts/remark-versioned-links.test.mjs
@@ -0,0 +1,194 @@
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import path from "node:path";
+import test from "node:test";
+import remarkMdx from "remark-mdx";
+import remarkParse from "remark-parse";
+import remarkStringify from "remark-stringify";
+import { unified } from "unified";
+import { archiveVersionOf } from "../docs/src/lib/docs-versions.ts";
+import { remarkVersionedLinks, scopeLink } from "../docs/src/lib/remark-versioned-links.ts";
+
+const ROOT = path.resolve(import.meta.dirname, "..");
+const CURRENT = path.join(ROOT, "docs", "content", "docs", "current");
+
+// Parse, run the plugin as Fumadocs would (with the source file's path on the
+// vfile), and write the tree back to MDX so assertions read like the source.
+function run(source, filePath) {
+ const processor = unified()
+ .use(remarkParse)
+ .use(remarkMdx)
+ .use(remarkVersionedLinks)
+ .use(remarkStringify);
+ return String(processor.processSync({ value: source, path: filePath }));
+}
+
+test("archiveVersionOf recognises vX.Y directories and nothing else", () => {
+ assert.equal(archiveVersionOf("v0.9/authentication.mdx"), "v0.9");
+ assert.equal(archiveVersionOf("v1.10/guides/setup.mdx"), "v1.10");
+ assert.equal(archiveVersionOf("/repo/docs/content/docs/v0.9/index.mdx"), "v0.9");
+ assert.equal(
+ archiveVersionOf("C:\\repo\\docs\\content\\docs\\v0.9\\index.mdx".replaceAll("\\", "/")),
+ "v0.9",
+ );
+ assert.equal(archiveVersionOf("current/authentication.mdx"), undefined);
+ assert.equal(archiveVersionOf("/repo/docs/content/docs/current/v0.9/x.mdx"), undefined);
+ assert.equal(archiveVersionOf("v0.9.1/x.mdx"), undefined);
+ assert.equal(archiveVersionOf("v0.9"), undefined);
+ assert.equal(archiveVersionOf("index.mdx"), undefined);
+});
+
+test("scopeLink rewrites unversioned docs paths only", () => {
+ assert.equal(scopeLink("/authentication", "v0.9"), "/v0.9/authentication");
+ assert.equal(scopeLink("/authentication#keys", "v0.9"), "/v0.9/authentication#keys");
+ assert.equal(scopeLink("/authentication?x=1#keys", "v0.9"), "/v0.9/authentication?x=1#keys");
+ assert.equal(scopeLink("/", "v0.9"), "/v0.9");
+ assert.equal(scopeLink("/#top", "v0.9"), "/v0.9#top");
+ assert.equal(scopeLink("/v0.9/authentication", "v0.9"), "/v0.9/authentication");
+ assert.equal(scopeLink("/v0.8/authentication", "v0.9"), "/v0.8/authentication");
+ assert.equal(scopeLink("/v0.9", "v0.9"), "/v0.9");
+ assert.equal(scopeLink("/portwing.png", "v0.9"), "/portwing.png");
+ assert.equal(scopeLink("/files/portwing.service", "v0.9"), "/files/portwing.service");
+ assert.equal(scopeLink("https://example.com/x", "v0.9"), "https://example.com/x");
+ assert.equal(scopeLink("//example.com/x", "v0.9"), "//example.com/x");
+ assert.equal(scopeLink("mailto:a@b.c", "v0.9"), "mailto:a@b.c");
+ assert.equal(scopeLink("#anchor", "v0.9"), "#anchor");
+ assert.equal(scopeLink("./sibling", "v0.9"), "./sibling");
+ assert.equal(scopeLink("sibling", "v0.9"), "sibling");
+});
+
+const SAMPLE = [
+ "[Auth](/authentication) and [anchor](/authentication#keys).",
+ "",
+ "[External](https://example.com/authentication) and [asset](/portwing.png).",
+ "",
+ "",
+ "",
+ "[ref][r]",
+ "",
+ "[r]: /configuration",
+ "",
+ ' ',
+ "",
+ "```md",
+ "[not a link](/authentication)",
+ "```",
+ "",
+ "`[inline](/authentication)`",
+ "",
+].join("\n");
+
+test("links in an archived page are scoped to its version", () => {
+ const out = run(SAMPLE, "/repo/docs/content/docs/v0.9/authentication.mdx");
+ assert.match(out, /\[Auth\]\(\/v0\.9\/authentication\)/u);
+ assert.match(out, /\[anchor\]\(\/v0\.9\/authentication#keys\)/u);
+ assert.match(out, /\[External\]\(https:\/\/example\.com\/authentication\)/u);
+ assert.match(out, /\[asset\]\(\/portwing\.png\)/u);
+ assert.match(out, /!\[logo\]\(\/portwing\.png\)/u);
+ assert.match(out, /\[r\]: \/v0\.9\/configuration/u);
+ assert.match(out, /href="\/v0\.9\/security-model"/u);
+ // Code is not a link: both forms are left exactly as written.
+ assert.match(out, /\[not a link\]\(\/authentication\)/u);
+ assert.match(out, /`\[inline\]\(\/authentication\)`/u);
+});
+
+test("native elements and basePath-prefixed targets are left alone", () => {
+ const file = "/repo/docs/content/docs/v0.9/x.mdx";
+ const out = run(
+ [' raw ', "", "[doc](/docs/authentication) [root](/docs)", ""].join(
+ "\n",
+ ),
+ file,
+ );
+ assert.match(out, /raw<\/a>/u);
+ assert.match(out, /\]\(\/docs\/authentication\)/u);
+ assert.match(out, /\]\(\/docs\)/u);
+ assert.equal(scopeLink("/docs", "v0.9"), "/docs");
+ assert.equal(scopeLink("/docs/authentication#x", "v0.9"), "/docs/authentication#x");
+ assert.equal(scopeLink("/docsify", "v0.9"), "/v0.9/docsify");
+});
+
+test("expression href attributes are scoped only when statically a string", () => {
+ const file = "/repo/docs/content/docs/v0.9/x.mdx";
+ const out = run(
+ [
+ ' ',
+ "",
+ " ",
+ "",
+ " ",
+ "",
+ " ",
+ "",
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: MDX source, not a JS template
+ " ",
+ "",
+ " ",
+ "",
+ ' ',
+ "",
+ ].join("\n"),
+ file,
+ );
+ assert.match(out, /href=\{"\/v0\.9\/authentication"\}/u);
+ assert.match(out, /href=\{"\/v0\.9\/configuration"\}/u);
+ assert.match(out, /href=\{`\/v0\.9\/security-model#a`\}/u);
+ assert.match(out, /href=\{base \+ '\/authentication'\}/u);
+ assert.match(out, /href=\{`\/\$\{slug\}`\}/u);
+ assert.match(out, /href=\{link\}/u);
+ assert.match(out, /href=\{"https:\/\/example\.com\/x"\}/u);
+});
+
+test("the plugin is a no-op for current/ and for paths with no version", () => {
+ for (const file of [
+ "/repo/docs/content/docs/current/authentication.mdx",
+ "current/authentication.mdx",
+ undefined,
+ ]) {
+ const scoped = run(SAMPLE, file);
+ const untouched = String(
+ unified().use(remarkParse).use(remarkMdx).use(remarkStringify).processSync(SAMPLE),
+ );
+ assert.equal(scoped, untouched, `${file} must come through unchanged`);
+ }
+});
+
+test("every real current/ page comes through the plugin byte-for-byte", () => {
+ const pages = fs.readdirSync(CURRENT).filter((name) => name.endsWith(".mdx"));
+ assert.ok(pages.length > 0);
+ for (const name of pages) {
+ const source = fs.readFileSync(path.join(CURRENT, name), "utf8");
+ const file = path.join(CURRENT, name);
+ const plain = String(
+ unified()
+ .use(remarkParse)
+ .use(remarkMdx)
+ .use(remarkStringify)
+ .processSync({ value: source, path: file }),
+ );
+ assert.equal(run(source, file), plain, `${name} changed under the plugin`);
+ }
+});
+
+test("a real current page scoped as an archive keeps every link inside the archive", () => {
+ const source = fs.readFileSync(path.join(CURRENT, "authentication.mdx"), "utf8");
+ const out = run(source, "/repo/docs/content/docs/v9.9/authentication.mdx");
+ const internal = [...out.matchAll(/\]\((\/[^)\s]*)\)/gu)].map((match) => match[1]);
+ assert.ok(internal.length > 0, "authentication.mdx should link to other docs pages");
+ for (const target of internal) {
+ assert.ok(target.startsWith("/v9.9/"), `${target} escaped the archive`);
+ }
+});
+
+test("the plugin is registered globally so the Fumadocs preset stays on", () => {
+ // A collection-level mdxOptions replaces the preset instead of extending it:
+ // the build stays green but code blocks lose highlighting and pages lose
+ // their table of contents. Only defineConfig({ mdxOptions }) layers on top.
+ const config = fs.readFileSync(path.join(ROOT, "docs", "source.config.ts"), "utf8");
+ const docsBlock = config.slice(
+ config.indexOf("defineDocs("),
+ config.indexOf("});", config.indexOf("defineDocs(")),
+ );
+ assert.doesNotMatch(docsBlock, /mdxOptions/u);
+ assert.match(config, /defineConfig\(\{\s*mdxOptions:\s*\{[^}]*remarkVersionedLinks/u);
+});
diff --git a/scripts/reusable-ci-config.test.mjs b/scripts/reusable-ci-config.test.mjs
index f2657aff..d8734eb4 100644
--- a/scripts/reusable-ci-config.test.mjs
+++ b/scripts/reusable-ci-config.test.mjs
@@ -14,7 +14,7 @@ const FIXED_SCRIPTS = new Map([
["go-test.sh", ["go mod verify", "go test -race", "COVERAGE_MIN"]],
[
"go-lint.sh",
- ["golangci-lint/v2/cmd/golangci-lint@v2.13.2", "GOLANGCI_LINT_CACHE", "mktemp -d"],
+ ["golangci-lint/v2/cmd/golangci-lint@v2.14.0", "GOLANGCI_LINT_CACHE", "mktemp -d"],
],
["go-govulncheck.sh", ["golang.org/x/vuln/cmd/govulncheck@v1.7.0"]],
["commit-message.sh", ["validate-commit-range-test.sh", "validate-commit-range.sh"]],
diff --git a/scripts/standard-mode-bind-config-test.sh b/scripts/standard-mode-bind-config-test.sh
index 9584f651..0a6d7f4e 100644
--- a/scripts/standard-mode-bind-config-test.sh
+++ b/scripts/standard-mode-bind-config-test.sh
@@ -98,9 +98,9 @@ require_publications "README.md" "yaml" 1 \
"the README copy of the standard-mode sockguard example"
require_publications "README.md" "docker run" 1 \
"README standard-mode docker run instructions"
-require_publications "docs/content/docs/getting-started.mdx" "yaml" 2 \
+require_publications "docs/content/docs/current/getting-started.mdx" "yaml" 2 \
"getting-started copies of the standard-mode Compose examples"
-require_publications "docs/content/docs/getting-started.mdx" "docker run" 1 \
+require_publications "docs/content/docs/current/getting-started.mdx" "docker run" 1 \
"getting-started standard-mode docker run instructions"
if ! grep -Fqx 'BIND_ADDRESS=127.0.0.1' scripts/install.sh; then
diff --git a/website/public/llms.txt b/website/public/llms.txt
index 11d117d8..94dc34ab 100644
--- a/website/public/llms.txt
+++ b/website/public/llms.txt
@@ -4,7 +4,7 @@
Portwing sits between the Drydock controller and the Docker Engine on each remote host. The controller connects inbound to Portwing over HTTP/SSE (standard mode), or Portwing dials outbound over a WebSocket tunnel when the host is behind NAT or a firewall (edge mode). All authentication is Ed25519 per-client signed requests (no shared secret on the wire) or Argon2id-hashed tokens. The audit log records every security-relevant action as structured JSON.
-Portwing v0.9.24 is a supported pre-v1 release. Its HTTP API, environment variables, MCP tool surface, and stable portwing/1.0 wire protocol follow the compatibility guarantees in STABILITY.md; operators should pin versions and review the changelog when upgrading. Full controller-owned watcher/update feature compatibility requires Drydock v1.6.0-rc.11+ while DrydockCompat remains 1.4.0.
+Portwing v0.9.25 is a supported pre-v1 release. Its HTTP API, environment variables, MCP tool surface, and stable portwing/1.0 wire protocol follow the compatibility guarantees in STABILITY.md; operators should pin versions and review the changelog when upgrading. Full controller-owned watcher/update feature compatibility requires Drydock v1.6.0-rc.11+ while DrydockCompat remains 1.4.0.
## Key capabilities
diff --git a/website/scripts/security-headers.test.mjs b/website/scripts/security-headers.test.mjs
index 41828959..0619b06f 100644
--- a/website/scripts/security-headers.test.mjs
+++ b/website/scripts/security-headers.test.mjs
@@ -53,6 +53,50 @@ test("static output paths map to clean public routes", () => {
assert.equal(routeForOutputPath("out/docs/security-model.html"), "/docs/security-model");
});
+test("archived docs keep their dotted version segment in the public route", () => {
+ // An archive's index exports as docs/v1.0.html, a sibling of its docs/v1.0/ pages.
+ assert.equal(routeForOutputPath("out/docs/v1.0.html"), "/docs/v1.0");
+ assert.equal(
+ routeForOutputPath("out/docs/v1.0/authentication.html"),
+ "/docs/v1.0/authentication",
+ );
+});
+
+test("build output serves archived docs at their dotted routes with per-page CSP", () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "portwing-bop-archive-"));
+ const source = path.join(root, "source");
+ const target = path.join(root, ".vercel", "output");
+ try {
+ fs.mkdirSync(path.join(source, "docs", "v1.0"), { recursive: true });
+ fs.writeFileSync(path.join(source, "docs", "v1.0.html"), "");
+ fs.writeFileSync(
+ path.join(source, "docs", "v1.0", "auth.html"),
+ "",
+ );
+
+ generateBuildOutput(source, target);
+
+ const config = JSON.parse(fs.readFileSync(path.join(target, "config.json"), "utf8"));
+ assert.deepEqual(config.overrides["docs/v1.0.html"], { path: "docs/v1.0" });
+ assert.deepEqual(config.overrides["docs/v1.0/auth.html"], { path: "docs/v1.0/auth" });
+
+ const indexRoute = config.routes.find((route) => route.src === "^/docs/v1\\.0/?$");
+ const authRoute = config.routes.find((route) => route.src === "^/docs/v1\\.0/auth/?$");
+ assert.ok(indexRoute, "the archive index needs its own route");
+ assert.ok(authRoute, "an archive page needs its own route");
+ assert.match(indexRoute.headers["Content-Security-Policy"], /script-src[^;]*'sha256-/);
+ assert.notEqual(
+ indexRoute.headers["Content-Security-Policy"],
+ authRoute.headers["Content-Security-Policy"],
+ );
+ // The dot is escaped, so the route cannot match some other path with any character there.
+ assert.equal(new RegExp(indexRoute.src).test("/docs/v1x0"), false);
+ assert.equal(new RegExp(indexRoute.src).test("/docs/v1.0"), true);
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+});
+
test("CSP permits only image origins present in the rendered page", () => {
const headers = headersForHTML(
' link ',
diff --git a/website/src/app/sitemap.ts b/website/src/app/sitemap.ts
index a5732278..e5b64c5b 100644
--- a/website/src/app/sitemap.ts
+++ b/website/src/app/sitemap.ts
@@ -6,7 +6,7 @@ export const dynamic = "force-static";
// Competitor compare page slugs — must match the routes under /compare/[slug].
const COMPARE_SLUGS = ["portainer", "komodo", "arcane", "hawser", "watchtower", "diun"] as const;
-// Docs page slugs enumerated from docs/content/docs/*.mdx.
+// Docs page slugs enumerated from docs/content/docs/current/*.mdx.
// index.mdx maps to /docs (listed separately below at priority 0.7).
const DOCS_SLUGS = [
"getting-started",
diff --git a/website/src/components/get-started.tsx b/website/src/components/get-started.tsx
index 1a808a38..0576cbb2 100644
--- a/website/src/components/get-started.tsx
+++ b/website/src/components/get-started.tsx
@@ -165,10 +165,10 @@ export function GetStarted() {
brew install --cask codeswhat/tap/portwing
# Debian / Ubuntu
-sudo apt install ./portwing_0.9.24_linux_amd64.deb
+sudo apt install ./portwing_0.9.25_linux_amd64.deb
# Fedora / RHEL
-sudo rpm --install ./portwing_0.9.24_linux_amd64.rpm`}
+sudo rpm --install ./portwing_0.9.25_linux_amd64.rpm`}
)}
diff --git a/website/src/lib/site-config.ts b/website/src/lib/site-config.ts
index cd50bb72..e5586ecf 100644
--- a/website/src/lib/site-config.ts
+++ b/website/src/lib/site-config.ts
@@ -14,7 +14,7 @@ export const SITE_CONFIG = {
/** Brand name shown in the header, footer, and metadata. */
name: "Portwing",
/** Current release version shown in the hero badge. */
- version: "0.9.24",
+ version: "0.9.25",
/** Short product tagline used in page titles and OG metadata. */
tagline: "Security-first remote Docker agent",
/**