Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
266056f
feat(docs): scope archived page links to their version at build time
scttbnsn Oct 9, 2026
f6c4fa6
feat(docs): mark archived pages noindex with an unsupported notice
scttbnsn Oct 9, 2026
8ba11eb
feat(docs): add a script to cut a docs archive from a release tag
scttbnsn Oct 9, 2026
908ed1b
test(docs): add the docs archive provenance contract and its self-test
scttbnsn Oct 9, 2026
ba9b324
ci(docs): run the docs archive contract in the release gates
scttbnsn Oct 9, 2026
e92b8ca
test(docs): keep archived docs out of the stale-version grep
scttbnsn Oct 9, 2026
6bac6c5
test(docs): check each archive exports under its own version segment
scttbnsn Oct 9, 2026
2ebfb08
test(website): cover dotted archive routes in the security headers ou…
scttbnsn Oct 9, 2026
4b0d259
docs: describe the docs archive layout and how to cut one
scttbnsn Oct 9, 2026
717aae4
fix(docs): scope archive links only where the router will honor them
scttbnsn Oct 9, 2026
289d416
fix(docs): require the archive for the previous distinct release line
scttbnsn Oct 9, 2026
2d105cb
fix(docs): make the archive cut atomic and its tests clean up after t…
scttbnsn Oct 9, 2026
c2933ff
docs: give the docs layout block a language
scttbnsn Oct 9, 2026
4d799d8
test(docs): keep hook git variables out of the archive fixture repos
scttbnsn Oct 9, 2026
0cd7f1a
fix(docs): harden the archive cut and the canonical URL
scttbnsn Oct 9, 2026
4725b35
fix(docs): read the provenance file without checking for it first
scttbnsn Oct 9, 2026
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
4 changes: 4 additions & 0 deletions .coderabbit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ reviews:
- "!website/public/**"
- "!docs/public/**"
- "!CHANGELOG.md"
# Docs archives are frozen copies of older releases, held to their release
# tag by scripts/docs-archive-config-test.sh. Review the provenance file
# (docs/content/archive-provenance.json) and that contract, not the copy.
- "!docs/content/docs/v*/**"

path_instructions:
- path: "internal/auth/**"
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/ci-verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,12 @@ jobs:
- name: Verify the package release contract
run: bash scripts/package-release-config-test.sh

# Archives under docs/content/docs/vX.Y/ must still be byte copies of the
# docs at their recorded release tag. Needs the full-depth checkout above
# for the tags; with no archives it only checks the rules that need none.
- name: Verify the docs archive contract
run: bash scripts/docs-archive-config-test.sh

# Drives the Compose containerd check against a fake `go` on PATH and
# canned build info, so it needs no toolchain and no network.
- name: Verify the Compose containerd check
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/release-cut.yml
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,11 @@ jobs:
- name: Verify the package release contract against the tag
run: bash scripts/package-release-config-test.sh

# A cut that moves current/ to a new line must already carry the archive
# of the line it leaves, built from that line's last tag.
- name: Verify the docs archive contract against the tag
run: bash scripts/docs-archive-config-test.sh

- name: Create and push release tag
env:
RELEASE_TAG: ${{ steps.next.outputs.release_tag }}
Expand Down
32 changes: 31 additions & 1 deletion RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@

6. **No source version bump needed** — the binary's version is injected at build time via GoReleaser ldflags (`-X github.com/codeswhat/portwing/internal/protocol.AgentVersion={{.Version}}`). `AgentVersion` in `internal/protocol/version.go` must stay a `var`: `-X` silently does nothing to a `const`.

7. **Lefthook pre-push** — runs automatically on `git push`. Sequence: clean-tree → release contract (`scripts/package-release-config-test.sh`) → goreleaser snapshot → lint → Qlty → test (-race) → govulncheck → fuzz smoke → actionlint → zizmor → web (`npm run check:web`). The push is blocked if any step fails.
7. **Lefthook pre-push** — runs automatically on `git push`. Sequence: clean-tree → release contract (`scripts/package-release-config-test.sh`) and docs archive contract (`scripts/docs-archive-config-test.sh`) → goreleaser snapshot → lint → Qlty → test (-race) → govulncheck → fuzz smoke → actionlint → zizmor → web (`npm run check:web`). The push is blocked if any step fails.

---

Expand Down Expand Up @@ -87,6 +87,36 @@ sit at "Expected" forever. So the order is:
Read the effective ruleset back afterward rather than trusting the PATCH's
200. The script prints it.

## Cutting a docs archive

Do this in the prep PR for a new minor or major line (v0.9 to v1.0, say),
never for a patch. The old line's docs get frozen as `docs/content/docs/vX.Y/`
and `current/` moves on to the new line. Layout and rules:
`docs/content/README.md`.

1. Make sure the old line is finished. The archive comes from its **last** tag,
so the final patch has to be tagged and fetched (`git fetch --tags`).
2. Retitle `docs/content/docs/current/meta.json` to the new line. The cut
refuses to archive the line `current/` still names.
3. Cut it from that tag:

```sh
npm run docs:archive -- vX.Y.Z # the old line's last tag
```

This reads `docs/content/docs/current` from the tag with git (never your
working tree), writes `docs/content/docs/vX.Y/`, adds the entry to
`docs/content/archive-provenance.json`, and lists `vX.Y` in
`docs/content/docs/meta.json` after `current`.
4. Run `bash scripts/docs-archive-config-test.sh` after committing the new
directory and the provenance entry together. The same contract runs in
`Release Contract`, `release-cut`, and pre-push; once the changelog's
previous release is on an older line than `current/`, it fails until that
line's archive exists.

Never edit a file under an archived `vX.Y/`. A correction goes in `current/`;
an archive that no longer matches its tag fails the contract.

## Cutting the tag

**Preferred path: use the `release-cut` workflow.**
Expand Down
43 changes: 43 additions & 0 deletions docs/content/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Docs content layout

This directory is not part of the docs site. Fumadocs ingests everything under
`docs/` only, so keep files that must not become pages out of that directory.
`docs/content/docs/` holds the pages; this directory holds the records about them.

## Versions

```text
docs/content/docs/
meta.json lists the version roots: current first, then archives newest first
current/ the live docs, served at /docs/<page>
vX.Y/ one frozen archive per retired release line, served at /docs/vX.Y/<page>
docs/content/archive-provenance.json where each archive came from
```

`current/meta.json` carries the line it documents as its title. That line has
no archive until `current/` moves on to the next one.

## Archives are immutable

An archive is a byte-for-byte copy of `current/` as it was at the last release
tag of its line. Nothing edits it afterwards, because the point of an archive
is to show what that release shipped with. A mistake found later is fixed in
`current/`.

Each archive has an entry in `archive-provenance.json` with `sourceTag`,
`sourceCommit`, `sourcePath` and `sourceTree`. `scripts/docs-archive-config-test.sh`
checks that the committed archive is exactly that tree, that the tag resolves
to that commit, and that the tag ends its line. Cut one with
`npm run docs:archive -- <tag>`; the steps are in `RELEASING.md`.

## How archives are published

- Links inside an archive keep the form they had at release (`/authentication`).
The `remarkVersionedLinks` plugin in `docs/source.config.ts` scopes them to
`/vX.Y/...` at build time, so the copy stays identical to the tag.
- Every archived page is `noindex`, shows an "archived, unsupported" notice
linking to the same page in the current docs, and is left out of the sitemap.
- The static export puts an archive's pages at `docs/out/vX.Y/<page>.html` and
its index at `docs/out/vX.Y.html`; `npm run docs:export-contract` checks both.
- Analytics counts archived paths under `/_other`; the allowlist in
`analytics/src/contract.ts` covers current pages only.
1 change: 1 addition & 0 deletions docs/content/archive-provenance.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
12 changes: 11 additions & 1 deletion docs/source.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import { defineConfig, defineDocs } from "fumadocs-mdx/config";
import { remarkVersionedLinks } from "./src/lib/remark-versioned-links.ts";

export const docs = defineDocs({
dir: "content/docs",
});

export default defineConfig();
// Global, not on the collection: a collection's own `mdxOptions` is used as-is,
// without the Fumadocs preset, which silently drops syntax highlighting and the
// table of contents. The global option layers this plugin on top of the preset.
// Archived vX.Y/ pages keep their release-time links; this scopes them to the
// archive at build time and does nothing for current/.
export default defineConfig({
mdxOptions: {
remarkPlugins: [remarkVersionedLinks],
},
});
47 changes: 46 additions & 1 deletion docs/src/app/[[...slug]]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,46 @@
import { Callout } from "fumadocs-ui/components/callout";
import defaultMdxComponents from "fumadocs-ui/mdx";
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from "fumadocs-ui/page";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { archiveVersionOf } from "@/lib/docs-versions";
import { BASE_URL } from "@/lib/site-config";
import { getDocsPage, source } from "@/lib/source";

// The same page in the current docs, as the slug list the loader knows it by.
// An archived page's slugs lead with the version segment, so dropping it gives
// the slug the page has under current/ (or none, for the version's index).
function currentSlugs(slugs: string[]): string[] {
return slugs.slice(1);
}

function hrefFor(slugs: string[]): string {
return slugs.length === 0 ? "/" : `/${slugs.join("/")}`;
}

export default async function Page(props: { params: Promise<{ slug?: string[] }> }) {
const params = await props.params;
const page = getDocsPage(params.slug);
if (!page) notFound();

const MDX = page.data.body;
const version = archiveVersionOf(page.path);
const counterpart = version ? currentSlugs(page.slugs) : [];
const hasCounterpart = version !== undefined && getDocsPage(counterpart) !== undefined;

return (
<DocsPage toc={page.data.toc} full={page.data.full}>
{version && (
<Callout type="warn" title={`Archived documentation for ${version}`}>
This page documents Portwing {version}, which is no longer supported. It is kept for
reference and is not updated.{" "}
<Link href={hasCounterpart ? hrefFor(counterpart) : "/"}>
{hasCounterpart ? "Read this page in the current docs" : "Go to the current docs"}
</Link>
.
</Callout>
)}
<DocsTitle>{page.data.title}</DocsTitle>
<DocsDescription>{page.data.description}</DocsDescription>
<DocsBody>
Expand All @@ -25,13 +54,29 @@ export function generateStaticParams() {
return source.generateParams();
}

export async function generateMetadata(props: { params: Promise<{ slug?: string[] }> }) {
export async function generateMetadata(props: {
params: Promise<{ slug?: string[] }>;
}): Promise<Metadata> {
const params = await props.params;
const page = getDocsPage(params.slug);
if (!page) notFound();

const version = archiveVersionOf(page.path);
if (version) {
// Archives are frozen reference copies: out of search results, but crawlers
// may still follow their links on to the current docs.
return {
title: `${page.data.title} (${version})`,
description: page.data.description,
robots: { index: false, follow: true },
};
}

return {
title: page.data.title,
description: page.data.description,
alternates: {
canonical: new URL(`/docs${hrefFor(page.slugs).replace(/^\/$/u, "")}`, BASE_URL).toString(),
},
};
}
19 changes: 19 additions & 0 deletions docs/src/lib/docs-versions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,25 @@ import { getSlugs } from "fumadocs-core/source";
// 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";
const ARCHIVE_DIR = /^v\d+\.\d+$/u;
const CONTENT_MARKER = "/content/docs/";

/**
* The archived version a docs source path belongs to ("v0.9"), or undefined
* for `current/` and anything else. This is the one place that decides what
* counts as an archive: the link scoping plugin and the page renderer both ask
* here. Accepts the loader's page path (`v0.9/authentication.mdx`) or an
* absolute file path that contains `content/docs/`.
*/
export function archiveVersionOf(path: string): string | undefined {
const normalized = path.replaceAll("\\", "/");
const marker = normalized.lastIndexOf(CONTENT_MARKER);
const relative = marker === -1 ? normalized : normalized.slice(marker + CONTENT_MARKER.length);
const separator = relative.indexOf("/");
if (separator === -1) return undefined;
const dir = relative.slice(0, separator);
return ARCHIVE_DIR.test(dir) ? dir : undefined;
}

/**
* For the Fumadocs loader's `slugs` option. A page under `current/` gets the
Expand Down
Loading