From 13ea5d040a54671b90ef48cfea11217f976e05c2 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:57:29 -0400 Subject: [PATCH 1/7] refactor(docs): move the docs content under current/ --- docs/content/docs/{ => current}/api-reference.mdx | 0 docs/content/docs/{ => current}/audit-logging.mdx | 0 docs/content/docs/{ => current}/authentication.mdx | 0 docs/content/docs/{ => current}/competitive-landscape.mdx | 0 docs/content/docs/{ => current}/configuration.mdx | 0 docs/content/docs/{ => current}/connection-modes.mdx | 0 docs/content/docs/{ => current}/drydock-integration.mdx | 0 docs/content/docs/{ => current}/getting-started.mdx | 0 docs/content/docs/{ => current}/index.mdx | 0 docs/content/docs/{ => current}/installation.mdx | 0 docs/content/docs/{ => current}/mcp-server.mdx | 0 docs/content/docs/{ => current}/meta.json | 0 docs/content/docs/{ => current}/migrating-from-watchtower.mdx | 0 docs/content/docs/{ => current}/observability.mdx | 0 docs/content/docs/{ => current}/security-model.mdx | 0 docs/content/docs/{ => current}/stability-policy.mdx | 0 docs/content/docs/{ => current}/standalone-mode.mdx | 0 docs/content/docs/{ => current}/verification.mdx | 0 18 files changed, 0 insertions(+), 0 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%) rename docs/content/docs/{ => current}/meta.json (100%) 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%) 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/meta.json b/docs/content/docs/current/meta.json similarity index 100% rename from docs/content/docs/meta.json rename to docs/content/docs/current/meta.json 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 From 77d74b53d751ea815eef930cc98e7886abf65b7d Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:58:35 -0400 Subject: [PATCH 2/7] feat(docs): serve content/docs/current at the unversioned docs URLs --- docs/content/docs/current/meta.json | 3 ++- docs/content/docs/meta.json | 1 + docs/src/app/layout.tsx | 2 ++ docs/src/lib/docs-versions.ts | 34 +++++++++++++++++++++++++++++ docs/src/lib/source.ts | 3 +++ 5 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 docs/content/docs/meta.json create mode 100644 docs/src/lib/docs-versions.ts diff --git a/docs/content/docs/current/meta.json b/docs/content/docs/current/meta.json index 95c250aa..eee16183 100644 --- a/docs/content/docs/current/meta.json +++ b/docs/content/docs/current/meta.json @@ -1,5 +1,6 @@ { - "title": "Portwing", + "title": "v0.9", + "root": true, "pages": [ "index", "getting-started", diff --git a/docs/content/docs/meta.json b/docs/content/docs/meta.json new file mode 100644 index 00000000..408459a5 --- /dev/null +++ b/docs/content/docs/meta.json @@ -0,0 +1 @@ +{"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/. +export const CURRENT_DIR = "current"; + +export const VERSION_DIR_PATTERN = /^v\d+\.\d+$/; + +/** + * Slugs for a content file path relative to content/docs, for the Fumadocs + * loader's `slugs` option: drops the leading `current` segment and the page + * extension, and maps `index` to the directory itself. + */ +export function slugsForPath(path: string): string[] { + const segments = path.split("/").filter((segment) => segment.length > 0); + const last = segments.pop(); + if (last === undefined) return []; + const name = last.replace(/\.[^.]+$/, ""); + if (segments[0] === CURRENT_DIR) segments.shift(); + if (name !== "index") segments.push(name); + return segments.map((segment) => encodeURI(segment)); +} + +/** + * 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]; From babaabe64e23a108a03ee94d89ab4e201a4c7ea7 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:03:55 -0400 Subject: [PATCH 3/7] refactor(docs): keep only the used exports in docs-versions --- docs/src/lib/docs-versions.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/src/lib/docs-versions.ts b/docs/src/lib/docs-versions.ts index 7ed7f65b..836d38e3 100644 --- a/docs/src/lib/docs-versions.ts +++ b/docs/src/lib/docs-versions.ts @@ -4,9 +4,7 @@ import type { Root } from "fumadocs-core/page-tree"; // directory per archived release. `current` is the live docs and must keep // serving at /docs/, so its directory segment never reaches the URL. // Archived versions keep their segment: vX.Y/ serves at /docs/vX.Y/. -export const CURRENT_DIR = "current"; - -export const VERSION_DIR_PATTERN = /^v\d+\.\d+$/; +const CURRENT_DIR = "current"; /** * Slugs for a content file path relative to content/docs, for the Fumadocs From 7852c067324d209d2034d4665ad6027e3fc80c45 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:03:55 -0400 Subject: [PATCH 4/7] test(docs): pin the current/ docs paths, version title and export contract --- package.json | 3 +- scripts/docs-export-contract.mjs | 53 ++++++++++ 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 +- 6 files changed, 211 insertions(+), 28 deletions(-) create mode 100644 scripts/docs-export-contract.mjs create mode 100644 scripts/docs-versions.test.mjs 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..a5ca8f0a --- /dev/null +++ b/scripts/docs-export-contract.mjs @@ -0,0 +1,53 @@ +// 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); + 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))); +} + +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)); + for (const page of pages) { + const slugs = slugsFor(page); + const html = slugs.length === 0 ? "index.html" : `${slugs.join("/")}.html`; + if (!fs.existsSync(path.join(OUT, html))) { + problems.push(`current/${page} must export as docs/out/${html}`); + } + } + for (const entry of fs.readdirSync(OUT, { recursive: true })) { + if (path.basename(String(entry)).startsWith(CURRENT_DIR)) { + problems.push(`docs/out/${entry} leaks the ${CURRENT_DIR} directory into the export`); + } + } +} + +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 From e054397f53ba40f040ee32d8d0d13d7d08b916c6 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:04:05 -0400 Subject: [PATCH 5/7] docs: point prose references at docs/content/docs/current --- .../workflows/quality-integration-engines.yml | 2 +- COMPATIBILITY.md | 2 +- ROADMAP.md | 16 +++++++++------- SECURITY-ASSURANCE.md | 2 +- cmd/portwing/main.go | 2 +- website/src/app/sitemap.ts | 2 +- 6 files changed, 14 insertions(+), 12 deletions(-) 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..5877018d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -88,13 +88,15 @@ 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. +- **Docs stay current-only until v1.0.** `docs/content/docs/current/` holds the + one tree tracking the latest release, served at the unversioned `/docs/...` + URLs, with no per-version archive. That's deliberate through `v0.9.x`. The + `current/` directory is the first step toward drydock's + `content/docs/{current,v1.x}` split: an archived release would be added as a + sibling `vX.Y/` directory. The v1.0.0 release-cut PR is the trigger to + decide whether the semver guarantees in [STABILITY.md](STABILITY.md) need the + rest of a versioned docs archive (drydock's `archive-provenance.json` and + its release-docs-archive tests) before v1.0 ships. - **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/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 b11a4a636197fbaac2eadd89d5d60e923d6d6b31 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:08:18 -0400 Subject: [PATCH 6/7] docs(roadmap): state the v1.0 docs archive plan --- ROADMAP.md | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 5877018d..a07a4821 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -88,15 +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/current/` holds the - one tree tracking the latest release, served at the unversioned `/docs/...` - URLs, with no per-version archive. That's deliberate through `v0.9.x`. The - `current/` directory is the first step toward drydock's - `content/docs/{current,v1.x}` split: an archived release would be added as a - sibling `vX.Y/` directory. The v1.0.0 release-cut PR is the trigger to - decide whether the semver guarantees in [STABILITY.md](STABILITY.md) need the - rest of a versioned docs archive (drydock's `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, From 3475c7c579e97e4b7688c70dbdf5dd01fd597bd9 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Fri, 9 Oct 2026 13:28:11 -0400 Subject: [PATCH 7/7] fix(docs): take slugs from Fumadocs and compare the exported page set --- docs/src/lib/docs-versions.ts | 24 +++++++++++---------- scripts/docs-export-contract.mjs | 37 ++++++++++++++++++++++++++------ 2 files changed, 43 insertions(+), 18 deletions(-) diff --git a/docs/src/lib/docs-versions.ts b/docs/src/lib/docs-versions.ts index 836d38e3..cf29148d 100644 --- a/docs/src/lib/docs-versions.ts +++ b/docs/src/lib/docs-versions.ts @@ -1,4 +1,5 @@ import type { Root } from "fumadocs-core/page-tree"; +import { getSlugs } from "fumadocs-core/source"; // The docs tree is content/docs/current/ plus, later, one content/docs/vX.Y/ // directory per archived release. `current` is the live docs and must keep @@ -7,18 +8,19 @@ import type { Root } from "fumadocs-core/page-tree"; const CURRENT_DIR = "current"; /** - * Slugs for a content file path relative to content/docs, for the Fumadocs - * loader's `slugs` option: drops the leading `current` segment and the page - * extension, and maps `index` to the directory itself. + * 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[] { - const segments = path.split("/").filter((segment) => segment.length > 0); - const last = segments.pop(); - if (last === undefined) return []; - const name = last.replace(/\.[^.]+$/, ""); - if (segments[0] === CURRENT_DIR) segments.shift(); - if (name !== "index") segments.push(name); - return segments.map((segment) => encodeURI(segment)); +export function slugsForPath(path: string): string[] | undefined { + const prefix = `${CURRENT_DIR}/`; + if (!path.startsWith(prefix)) return undefined; + return getSlugs(path.slice(prefix.length)); } /** diff --git a/scripts/docs-export-contract.mjs b/scripts/docs-export-contract.mjs index a5ca8f0a..634876c4 100644 --- a/scripts/docs-export-contract.mjs +++ b/scripts/docs-export-contract.mjs @@ -14,7 +14,10 @@ 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); + const segments = file + .replace(/\.mdx?$/u, "") + .split(path.sep) + .filter((segment) => !/^\(.+\)$/u.test(segment)); if (segments.at(-1) === "index") segments.pop(); return segments; } @@ -26,22 +29,42 @@ function listFiles(dir) { .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); - const html = slugs.length === 0 ? "index.html" : `${slugs.join("/")}.html`; - if (!fs.existsSync(path.join(OUT, html))) { - problems.push(`current/${page} must export as docs/out/${html}`); + 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 entry of fs.readdirSync(OUT, { recursive: true })) { - if (path.basename(String(entry)).startsWith(CURRENT_DIR)) { - problems.push(`docs/out/${entry} leaks the ${CURRENT_DIR} directory into the export`); + 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`, + ); } } }