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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/quality-integration-engines.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
15 changes: 8 additions & 7 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion SECURITY-ASSURANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cmd/portwing/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
File renamed without changes.
23 changes: 23 additions & 0 deletions docs/content/docs/current/meta.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
23 changes: 1 addition & 22 deletions docs/content/docs/meta.json
Original file line number Diff line number Diff line change
@@ -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"]}
2 changes: 2 additions & 0 deletions docs/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -73,6 +74,7 @@ export default function RootLayout({
*/}
<DocsLayout
tree={source.pageTree}
tabs={versionTabsOption(source.pageTree)}
nav={{ enabled: false }}
themeSwitch={{ enabled: false }}
searchToggle={{ enabled: false }}
Expand Down
34 changes: 34 additions & 0 deletions docs/src/lib/docs-versions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
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
// serving at /docs/<page>, so its directory segment never reaches the URL.
// Archived versions keep their segment: vX.Y/<page> serves at /docs/vX.Y/<page>.
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;
}
3 changes: 3 additions & 0 deletions docs/src/lib/source.ts
Original file line number Diff line number Diff line change
@@ -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];
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
76 changes: 76 additions & 0 deletions scripts/docs-export-contract.mjs
Original file line number Diff line number Diff line change
@@ -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.");
111 changes: 111 additions & 0 deletions scripts/docs-versions.test.mjs
Original file line number Diff line number Diff line change
@@ -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, []);
});
Loading
Loading