From 3756d93fb2372cba3534c0f6f4b9fd5c9a7bd0aa Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:56:42 +0000 Subject: [PATCH 1/6] chore(docs): refresh the star-history chart --- docs/assets/star-history-dark.svg | 20 ++++++++++---------- docs/assets/star-history.svg | 20 ++++++++++---------- 2 files changed, 20 insertions(+), 20 deletions(-) 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 12Jun 27Jul 12Jul 26Aug 10 - 1234 + + + + JunJulAugSepOct + 24 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 12Jun 27Jul 12Jul 26Aug 10 - 1234 + + + + JunJulAugSepOct + 24 From 0c134467aefeedccf95f292a0d5ff127b5919b5f Mon Sep 17 00:00:00 2001 From: Scott Benson <80784472+scttbnsn@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:30:22 -0400 Subject: [PATCH 2/6] build(deps): update the Go toolchain to 1.27.2 (#382) * build(deps): update the Go toolchain to 1.27.2 * build(lint): move golangci-lint to v2.14.0 for Go 1.27.2 --- .clusterfuzzlite/Dockerfile | 4 ++-- .github/workflows/quality-fuzz-cflite-pr.yml | 4 ++-- BENCHMARKS.md | 2 +- CHANGELOG.md | 9 +++++++++ Dockerfile | 2 +- Dockerfile.armv7 | 4 ++-- Dockerfile.dev | 2 +- Dockerfile.release | 2 +- RELEASING.md | 2 +- SPEC.md | 2 +- go.mod | 2 +- scripts/cflite-config-test.sh | 2 +- scripts/ci/go-lint.sh | 2 +- scripts/compose-containerd-version-test.sh | 8 ++++---- scripts/reusable-ci-config.test.mjs | 2 +- 15 files changed, 29 insertions(+), 20 deletions(-) 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/.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/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..cbd7d63b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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. + The lint script moves to golangci-lint v2.14.0, because v2.13.2 cannot read + the export data Go 1.27.2 writes. + ## [v0.9.24] - 2026-10-08 ### Fixed 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/RELEASING.md b/RELEASING.md index 76a78384..37e1b323 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -199,7 +199,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/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/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/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/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 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/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"]], From 868ab70ba6a4d3a4977945ff6693cf2c64b6b1ae Mon Sep 17 00:00:00 2001 From: Scott Benson <80784472+scttbnsn@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:46:23 -0400 Subject: [PATCH 3/6] feat(docs): move the docs under current/ with URLs unchanged (#383) * refactor(docs): move the docs content under current/ * feat(docs): serve content/docs/current at the unversioned docs URLs * refactor(docs): keep only the used exports in docs-versions * test(docs): pin the current/ docs paths, version title and export contract * docs: point prose references at docs/content/docs/current * docs(roadmap): state the v1.0 docs archive plan * fix(docs): take slugs from Fumadocs and compare the exported page set --- .../workflows/quality-integration-engines.yml | 2 +- COMPATIBILITY.md | 2 +- ROADMAP.md | 15 +-- SECURITY-ASSURANCE.md | 2 +- cmd/portwing/main.go | 2 +- .../docs/{ => current}/api-reference.mdx | 0 .../docs/{ => current}/audit-logging.mdx | 0 .../docs/{ => current}/authentication.mdx | 0 .../{ => current}/competitive-landscape.mdx | 0 .../docs/{ => current}/configuration.mdx | 0 .../docs/{ => current}/connection-modes.mdx | 0 .../{ => current}/drydock-integration.mdx | 0 .../docs/{ => current}/getting-started.mdx | 0 docs/content/docs/{ => current}/index.mdx | 0 .../docs/{ => current}/installation.mdx | 0 .../content/docs/{ => current}/mcp-server.mdx | 0 docs/content/docs/current/meta.json | 23 ++++ .../migrating-from-watchtower.mdx | 0 .../docs/{ => current}/observability.mdx | 0 .../docs/{ => current}/security-model.mdx | 0 .../docs/{ => current}/stability-policy.mdx | 0 .../docs/{ => current}/standalone-mode.mdx | 0 .../docs/{ => current}/verification.mdx | 0 docs/content/docs/meta.json | 23 +--- docs/src/app/layout.tsx | 2 + docs/src/lib/docs-versions.ts | 34 ++++++ docs/src/lib/source.ts | 3 + package.json | 3 +- scripts/docs-export-contract.mjs | 76 ++++++++++++ scripts/docs-versions.test.mjs | 111 ++++++++++++++++++ scripts/package-release-config-test-test.sh | 25 ++-- scripts/package-release-config-test.sh | 43 ++++--- scripts/standard-mode-bind-config-test.sh | 4 +- website/src/app/sitemap.ts | 2 +- 34 files changed, 310 insertions(+), 62 deletions(-) rename docs/content/docs/{ => current}/api-reference.mdx (100%) rename docs/content/docs/{ => current}/audit-logging.mdx (100%) rename docs/content/docs/{ => current}/authentication.mdx (100%) rename docs/content/docs/{ => current}/competitive-landscape.mdx (100%) rename docs/content/docs/{ => current}/configuration.mdx (100%) rename docs/content/docs/{ => current}/connection-modes.mdx (100%) rename docs/content/docs/{ => current}/drydock-integration.mdx (100%) rename docs/content/docs/{ => current}/getting-started.mdx (100%) rename docs/content/docs/{ => current}/index.mdx (100%) rename docs/content/docs/{ => current}/installation.mdx (100%) rename docs/content/docs/{ => current}/mcp-server.mdx (100%) create mode 100644 docs/content/docs/current/meta.json rename docs/content/docs/{ => current}/migrating-from-watchtower.mdx (100%) rename docs/content/docs/{ => current}/observability.mdx (100%) rename docs/content/docs/{ => current}/security-model.mdx (100%) rename docs/content/docs/{ => current}/stability-policy.mdx (100%) rename docs/content/docs/{ => current}/standalone-mode.mdx (100%) rename docs/content/docs/{ => current}/verification.mdx (100%) create mode 100644 docs/src/lib/docs-versions.ts create mode 100644 scripts/docs-export-contract.mjs create mode 100644 scripts/docs-versions.test.mjs 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/COMPATIBILITY.md b/COMPATIBILITY.md index 85d4f333..6e591a23 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -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/ROADMAP.md b/ROADMAP.md index e68b4e8d..a07a4821 100644 --- a/ROADMAP.md +++ b/ROADMAP.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/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/content/docs/api-reference.mdx b/docs/content/docs/current/api-reference.mdx similarity index 100% rename from docs/content/docs/api-reference.mdx rename to docs/content/docs/current/api-reference.mdx 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 100% rename from docs/content/docs/installation.mdx rename to docs/content/docs/current/installation.mdx 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 100% rename from docs/content/docs/observability.mdx rename to docs/content/docs/current/observability.mdx diff --git a/docs/content/docs/security-model.mdx b/docs/content/docs/current/security-model.mdx similarity index 100% rename from docs/content/docs/security-model.mdx rename to docs/content/docs/current/security-model.mdx 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 100% rename from docs/content/docs/standalone-mode.mdx rename to docs/content/docs/current/standalone-mode.mdx diff --git a/docs/content/docs/verification.mdx b/docs/content/docs/current/verification.mdx similarity index 100% rename from docs/content/docs/verification.mdx rename to docs/content/docs/current/verification.mdx 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/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"; + +/** + * 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/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/package.json b/package.json index 2eaffe85..cb72d666 100644 --- a/package.json +++ b/package.json @@ -14,11 +14,12 @@ "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: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", diff --git a/scripts/docs-export-contract.mjs b/scripts/docs-export-contract.mjs new file mode 100644 index 00000000..634876c4 --- /dev/null +++ b/scripts/docs-export-contract.mjs @@ -0,0 +1,76 @@ +// 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, and every current page must still land at its unversioned URL. +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 ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const CONTENT = path.join(ROOT, "docs", "content", "docs", CURRENT_DIR); +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"]); +// An archived release line exports under its own segment and is checked by its +// own contract; this one only owns the unversioned pages. +const ARCHIVE = /^v\d+\.\d+(?:\/|\.html$)/u; + +const problems = []; + +if (!fs.existsSync(OUT)) { + problems.push(`${path.relative(ROOT, OUT)} is missing; run the docs build first`); +} else { + const pages = listFiles(CONTENT).filter((file) => /\.mdx?$/u.test(file)); + const expected = new Map(); + for (const page of pages) { + const slugs = slugsFor(page); + expected.set(slugs.length === 0 ? "index.html" : `${slugs.join("/")}.html`, page); + } + if (!expected.has("index.html")) { + problems.push(`${CURRENT_DIR}/index.mdx is missing; /docs 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) && !ARCHIVE.test(file)), + ); + for (const [html, page] of expected) { + if (!exported.has(html)) { + problems.push(`${CURRENT_DIR}/${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}/; a directory leaked into the URL or a page went missing from the source`, + ); + } + } +} + +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-versions.test.mjs b/scripts/docs-versions.test.mjs new file mode 100644 index 00000000..22203730 --- /dev/null +++ b/scripts/docs-versions.test.mjs @@ -0,0 +1,111 @@ +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); +}); + +test("root-relative links resolve to a page in the same version dir", () => { + const dead = []; + for (const dir of versionDirs()) { + const slugs = new Set(pageSlugs(dir)); + // current serves at the docs root; an archived vX.Y serves under /vX.Y. + const prefix = dir === "current" ? "/" : `/${dir}/`; + for (const slug of slugs) { + 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("#"); + const relative = target.startsWith(prefix) ? target.slice(prefix.length) : null; + const resolved = relative === null ? null : relative.replace(/\/$/u, "") || "index"; + if (resolved === null || !slugs.has(resolved)) { + dead.push(`${dir}/${slug}: ${match[1]}`); + } + } + } + } + assert.deepEqual(dead, []); +}); diff --git a/scripts/package-release-config-test-test.sh b/scripts/package-release-config-test-test.sh index 0909429f..012a2ab3 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}" diff --git a/scripts/package-release-config-test.sh b/scripts/package-release-config-test.sh index 9cbdb0c9..5509e40c 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" @@ -802,7 +811,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 +821,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/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/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", From 9987025334ea52be0602522ec7febd9199c1aa05 Mon Sep 17 00:00:00 2001 From: Scott Benson <80784472+scttbnsn@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:24:59 -0400 Subject: [PATCH 4/6] feat(docs): add the docs archive cut, provenance and contract (#384) * feat(docs): scope archived page links to their version at build time * feat(docs): mark archived pages noindex with an unsupported notice * feat(docs): add a script to cut a docs archive from a release tag * test(docs): add the docs archive provenance contract and its self-test * ci(docs): run the docs archive contract in the release gates * test(docs): keep archived docs out of the stale-version grep * test(docs): check each archive exports under its own version segment * test(website): cover dotted archive routes in the security headers output * docs: describe the docs archive layout and how to cut one * fix(docs): scope archive links only where the router will honor them Leave native and /docs-prefixed targets alone, scope string-literal expression hrefs, validate cross-version links against the version they name, and add content tests forbidding the two link forms that cannot work. * fix(docs): require the archive for the previous distinct release line Walk the CHANGELOG past patch releases on the current line, compare lines numerically, and skip lines whose last tag predates the current/ layout. * fix(docs): make the archive cut atomic and its tests clean up after themselves Validate every input before writing, roll back on a failed write, suggest the numerically newest patch tag, and remove the temp repos the tests create. * docs: give the docs layout block a language * test(docs): keep hook git variables out of the archive fixture repos * fix(docs): harden the archive cut and the canonical URL * fix(docs): read the provenance file without checking for it first --- .coderabbit.yaml | 4 + .github/workflows/ci-verify.yml | 6 + .github/workflows/release-cut.yml | 5 + RELEASING.md | 32 +- docs/content/README.md | 43 +++ docs/content/archive-provenance.json | 1 + docs/source.config.ts | 12 +- docs/src/app/[[...slug]]/page.tsx | 47 ++- docs/src/lib/docs-versions.ts | 19 ++ docs/src/lib/remark-versioned-links.ts | 149 +++++++++ docs/tsconfig.json | 1 + lefthook.yml | 12 +- package-lock.json | 6 +- package.json | 7 +- scripts/ci/go-release-check.sh | 2 + scripts/docs-archive-config-test-test.sh | 346 ++++++++++++++++++++ scripts/docs-archive-config-test.sh | 213 ++++++++++++ scripts/docs-archive-cut.mjs | 242 ++++++++++++++ scripts/docs-archive-cut.test.mjs | 232 +++++++++++++ scripts/docs-export-contract.mjs | 46 ++- scripts/docs-export-contract.test.mjs | 103 ++++++ scripts/docs-versions.test.mjs | 84 ++++- scripts/package-release-config-test-test.sh | 21 ++ scripts/package-release-config-test.sh | 7 + scripts/remark-versioned-links.test.mjs | 194 +++++++++++ website/scripts/security-headers.test.mjs | 44 +++ 26 files changed, 1848 insertions(+), 30 deletions(-) create mode 100644 docs/content/README.md create mode 100644 docs/content/archive-provenance.json create mode 100644 docs/src/lib/remark-versioned-links.ts create mode 100755 scripts/docs-archive-config-test-test.sh create mode 100755 scripts/docs-archive-config-test.sh create mode 100644 scripts/docs-archive-cut.mjs create mode 100644 scripts/docs-archive-cut.test.mjs create mode 100644 scripts/docs-export-contract.test.mjs create mode 100644 scripts/remark-versioned-links.test.mjs 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/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/RELEASING.md b/RELEASING.md index 37e1b323..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.** 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/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/lib/docs-versions.ts b/docs/src/lib/docs-versions.ts index cf29148d..346e4083 100644 --- a/docs/src/lib/docs-versions.ts +++ b/docs/src/lib/docs-versions.ts @@ -6,6 +6,25 @@ import { getSlugs } from "fumadocs-core/source"; // serving at /docs/, 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 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/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/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 cb72d666..1f9bb2c5 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "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", @@ -26,7 +27,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" }, "overrides": { "postcss": "^8.5.28", 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/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 index 634876c4..cb4b53e6 100644 --- a/scripts/docs-export-contract.mjs +++ b/scripts/docs-export-contract.mjs @@ -1,14 +1,18 @@ // 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, and every current page must still land at its unversioned URL. +// 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 CONTENT = path.join(ROOT, "docs", "content", "docs", CURRENT_DIR); +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. @@ -31,39 +35,53 @@ function listFiles(dir) { // Pages Next emits for itself, not from content. const FRAMEWORK_PAGES = new Set(["404.html", "_not-found.html"]); -// An archived release line exports under its own segment and is checked by its -// own contract; this one only owns the unversioned pages. -const ARCHIVE = /^v\d+\.\d+(?:\/|\.html$)/u; + +// 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 pages = listFiles(CONTENT).filter((file) => /\.mdx?$/u.test(file)); - const expected = new Map(); - for (const page of pages) { - const slugs = slugsFor(page); - expected.set(slugs.length === 0 ? "index.html" : `${slugs.join("/")}.html`, page); - } + 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) && !ARCHIVE.test(file)), + .filter((file) => !FRAMEWORK_PAGES.has(file)), ); for (const [html, page] of expected) { if (!exported.has(html)) { - problems.push(`${CURRENT_DIR}/${page} must export as docs/out/${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}/; a directory leaked into the URL or a page went missing from the source`, + `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`, ); } } 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 index 22203730..cc4d0def 100644 --- a/scripts/docs-versions.test.mjs +++ b/scripts/docs-versions.test.mjs @@ -85,23 +85,65 @@ test("the analytics docs allowlist is exactly the current docs pages", () => { assert.deepEqual(listed, expected); }); -test("root-relative links resolve to a page in the same version dir", () => { - const dead = []; +// 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()) { - const slugs = new Set(pageSlugs(dir)); - // current serves at the docs root; an archived vX.Y serves under /vX.Y. - const prefix = dir === "current" ? "/" : `/${dir}/`; - for (const slug of slugs) { + 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("#"); - const relative = target.startsWith(prefix) ? target.slice(prefix.length) : null; - const resolved = relative === null ? null : relative.replace(/\/$/u, "") || "index"; - if (resolved === null || !slugs.has(resolved)) { + 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]}`); } } @@ -109,3 +151,25 @@ test("root-relative links resolve to a page in the same version dir", () => { } 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/package-release-config-test-test.sh b/scripts/package-release-config-test-test.sh index 012a2ab3..5a8a6edd 100644 --- a/scripts/package-release-config-test-test.sh +++ b/scripts/package-release-config-test-test.sh @@ -424,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 5509e40c..06b7800a 100755 --- a/scripts/package-release-config-test.sh +++ b/scripts/package-release-config-test.sh @@ -419,9 +419,16 @@ require_current_release_examples "docs/content/docs/current/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}" ' 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).", + "", + "![logo](/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/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', From eaed9c89f60e243a176fed86ac8dc20a60cfd3f7 Mon Sep 17 00:00:00 2001 From: Scott Benson <80784472+scttbnsn@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:29:46 -0400 Subject: [PATCH 5/6] fix(web): relaunch Chrome when it never opens its debugging port (#385) --- scripts/lighthouse-budget.mjs | 3 +++ scripts/lighthouse-budget.test.mjs | 3 +++ 2 files changed, 6 insertions(+) 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")), ); From 60fa93be8f3fcf6d14ab15f3ea5dd8e0a420d1a7 Mon Sep 17 00:00:00 2001 From: Scott Benson <80784472+scttbnsn@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:24:08 -0400 Subject: [PATCH 6/6] chore(release): prepare v0.9.25 (#386) --- CHANGELOG.md | 10 ++++++++++ COMPATIBILITY.md | 2 +- README.md | 14 ++++++------- ROADMAP.md | 2 +- api/openapi.yaml | 10 +++++----- docs/content/docs/current/api-reference.mdx | 8 ++++---- docs/content/docs/current/installation.mdx | 4 ++-- docs/content/docs/current/observability.mdx | 8 ++++---- docs/content/docs/current/security-model.mdx | 2 +- docs/content/docs/current/standalone-mode.mdx | 4 ++-- docs/content/docs/current/verification.mdx | 20 +++++++++---------- examples/observability/docker-compose.yml | 2 +- website/public/llms.txt | 2 +- website/src/components/get-started.tsx | 4 ++-- website/src/lib/site-config.ts | 2 +- 15 files changed, 52 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbd7d63b..f8cdb2b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,15 +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 6e591a23..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 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/ROADMAP.md b/ROADMAP.md index a07a4821..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). 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/docs/content/docs/current/api-reference.mdx b/docs/content/docs/current/api-reference.mdx index 1a57d657..e2b64ba1 100644 --- a/docs/content/docs/current/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/current/installation.mdx b/docs/content/docs/current/installation.mdx index 5a40b3c8..a4d59e86 100644 --- a/docs/content/docs/current/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/current/observability.mdx b/docs/content/docs/current/observability.mdx index d8f9e0f8..cb8f8bde 100644 --- a/docs/content/docs/current/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/current/security-model.mdx b/docs/content/docs/current/security-model.mdx index 27d3deda..fa87cd5e 100644 --- a/docs/content/docs/current/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/current/standalone-mode.mdx b/docs/content/docs/current/standalone-mode.mdx index 0b2830ed..f01c97f4 100644 --- a/docs/content/docs/current/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/current/verification.mdx b/docs/content/docs/current/verification.mdx index b82c249b..703c0494 100644 --- a/docs/content/docs/current/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/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/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/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", /**