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",