Skip to content

Commit dd778dc

Browse files
committed
feat(FR-2718): F6 versioned docs and minor-grained version selector (#7011)
Resolves FR-2718 ## Summary F6 of FR-2710: versioned docs and minor-grained version selector. - New `versions` config key in `docs-toolkit.config.yaml` (opt-in) - Output layout `dist/web/<version>/<lang>/...` when `versions` declared; flat layout preserved when undeclared - Per-version search index; per-version internal links / TOC scope - Header version dropdown (minor-grained; never lists patches) - Cross-version slug fallback: same-slug if exists, else target version's index page - Canonical / hreflang strategy: canonical → latest's same slug; hreflang → within same version only - New CI workflow `docs-archive-orphan-branch.yml` to create + push `docs-archive/<minor>` orphan branches at release time - Backward compat: legacy flat layout preserved when `versions` undeclared - 19 unit tests via `node:test` + `tsx` ## Review fixes applied (commit `bf3cee0c`) - HIGH: `slugAvailability` ternary fix — version switcher now correctly falls back to version index when slug missing in target version (was always-true → spec-violation 404) - MEDIUM: `versions[].label` regex tightened to `^[0-9]+\.[0-9]+$` to match GitHub workflow validation - MEDIUM: `data-availability` attribute switched to double-quoted form (escapeHtml does not escape `'`) - MEDIUM: `safeRef` rejects `..` segments, leading slash, and leading dot - 5 new unit tests added (14 → 19 total) ## Cross-cutting verification - [x] `pnpm run build:web` (no `versions` declared) → flat layout, exit 0 - [x] `pnpm run build:web` (with workspace `versions`) → versioned `dist/web/<v>/<lang>/...` layout, exit 0 - [x] `pnpm --filter backend.ai-docs-toolkit test` → 19/19 pass - [x] `pnpm run pdf:en` exits 0 with and without `versions` declared; PDF reads only `book.config.yaml`, `versions` invisible to PDF - Pre-existing CJK PDF break unchanged ## Eligible-minor enumeration deferred Per spec scope decision, this PR ships the schema + selector mechanism + orphan-branch CI workflow but does NOT enumerate past minors. That is operational rollout once archive branches exist. ## Deferred to follow-up - "This page is not in selected version" inline notice - "View latest version" inline banner (when reading a non-latest version) - Stale comment cleanup at `website-generator.ts:663–688` (still describes old "conservative true" behavior; out of sync with the fix at line 715) ## API for F2 (sitemap + canonical) F6 exports `enumerateAllPages()`, `canonicalPathFor(loaded, lang, slug)`, `VersionPageRegistry`, `Version`, `LoadedVersions`, `PageEnumerationRow`, `ResolvedVersionSource` for F2 to consume. ## Stack - Spec: #6988 → Dev plan: #7004 → F5: #7006 → This PR (F6) - Child stacked on F6: #7012 (F2)
1 parent e9cdc90 commit dd778dc

11 files changed

Lines changed: 1901 additions & 232 deletions

File tree

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
# Build the WebUI docs site for a specific minor version with the
2+
# toolkit-of-its-day, then commit + push the artifacts to a docs-archive
3+
# orphan branch (e.g., `docs-archive/25.16`).
4+
#
5+
# Why orphan branches?
6+
# Past minors are NOT rebuilt with the current toolkit on every deploy
7+
# — markdown structure of older minors may diverge enough to break the
8+
# build. Instead, each minor is built ONCE with the toolkit it shipped
9+
# with and parked on a dedicated orphan branch. Orphan branches share
10+
# no history with `main`, so the main repo size is unaffected.
11+
#
12+
# Trigger: manual (`workflow_dispatch`) for v1. The operator picks the
13+
# tagged commit they want to immortalize and the minor label that
14+
# represents it. Future iteration may add an on-tag trigger but that is
15+
# explicitly out of scope here.
16+
#
17+
# Output: a `docs-archive/<minor>` branch containing only the contents
18+
# of `dist/web/<minor>/` (so the archived site is the FILES, not the
19+
# source tree). The deployment pipeline (AWS Amplify or equivalent)
20+
# checks out `docs-archive/<minor>` and serves it under `/<minor>/...`.
21+
# The deployment pipeline itself is deferred to a separate spec.
22+
#
23+
# References:
24+
# - Spec: .specs/FR-2710-docs-site-production-uplift/spec.md (F6)
25+
# - Issue: FR-2718 / gh#7003
26+
27+
name: docs-archive-orphan-branch
28+
29+
on:
30+
workflow_dispatch:
31+
inputs:
32+
source_ref:
33+
description: "Source ref to build from (commit SHA or tag, e.g. v25.16.5)"
34+
required: true
35+
type: string
36+
minor_label:
37+
description: "Minor label this archive represents (e.g. 25.16). MUST NOT include patch."
38+
required: true
39+
type: string
40+
dry_run:
41+
description: "If true, build and inspect only — do NOT push the orphan branch"
42+
required: false
43+
type: boolean
44+
default: false
45+
46+
permissions:
47+
contents: write
48+
49+
jobs:
50+
build-and-archive:
51+
runs-on: ubuntu-latest
52+
timeout-minutes: 30
53+
steps:
54+
- name: Validate minor label shape
55+
# Refuse anything that looks like a full patch (three numeric segments)
56+
# to mirror the validation in `versions.ts` — the selector lists minors
57+
# only, never patches. Inputs flow through env vars (NOT shell
58+
# interpolation) to avoid command injection from malicious input.
59+
env:
60+
MINOR_LABEL: ${{ inputs.minor_label }}
61+
run: |
62+
if [[ "$MINOR_LABEL" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then
63+
echo "ERROR: minor_label '$MINOR_LABEL' looks like a patch."
64+
echo "Expected format: '<MAJOR>.<MINOR>' (e.g. 25.16). The selector lists minors only."
65+
exit 1
66+
fi
67+
if [[ ! "$MINOR_LABEL" =~ ^[0-9]+\.[0-9]+$ ]]; then
68+
echo "ERROR: minor_label '$MINOR_LABEL' is not a clean MAJOR.MINOR pair."
69+
exit 1
70+
fi
71+
72+
- name: Checkout source ref (toolkit-of-its-day)
73+
uses: actions/checkout@v4
74+
with:
75+
ref: ${{ inputs.source_ref }}
76+
fetch-depth: 0
77+
78+
- name: Setup pnpm
79+
uses: pnpm/action-setup@v4
80+
81+
- name: Setup Node.js
82+
uses: actions/setup-node@v4
83+
with:
84+
node-version: 20
85+
cache: "pnpm"
86+
87+
- name: Install dependencies
88+
run: pnpm install --frozen-lockfile
89+
90+
- name: Build website (toolkit-of-its-day)
91+
# We deliberately use the toolkit version that shipped with this
92+
# source ref. Past minors are NEVER rebuilt with main's toolkit.
93+
run: pnpm --filter backend.ai-webui-docs run build:web
94+
95+
- name: Verify build output exists
96+
run: |
97+
DIST="packages/backend.ai-webui-docs/dist/web"
98+
if [ ! -d "$DIST" ]; then
99+
echo "ERROR: expected build output at $DIST"
100+
exit 1
101+
fi
102+
echo "Build output:"
103+
ls -la "$DIST"
104+
105+
- name: Stage build artifact for archive
106+
# The archive branch should hold ONLY the rendered site, with no
107+
# source files or lockfiles. We collect the output into a temp
108+
# directory the orphan branch is built from. Inputs flow through
109+
# env vars to avoid shell injection.
110+
env:
111+
MINOR_LABEL: ${{ inputs.minor_label }}
112+
SOURCE_REF: ${{ inputs.source_ref }}
113+
RUN_ID: ${{ github.run_id }}
114+
run: |
115+
mkdir -p /tmp/docs-archive
116+
cp -R packages/backend.ai-webui-docs/dist/web/. /tmp/docs-archive/
117+
# Drop any version-prefixed subdirectory: the archive branch
118+
# represents a SINGLE minor, so its root is the rendered site.
119+
# If the source ref already used `versions:` mode the output is
120+
# `dist/web/<v>/<lang>/...`; flatten it.
121+
cd /tmp/docs-archive
122+
if [ -d "$MINOR_LABEL" ]; then
123+
mv "$MINOR_LABEL"/* .
124+
rmdir "$MINOR_LABEL"
125+
fi
126+
echo "Archive contents:"
127+
ls -la
128+
# Drop a sentinel file so anyone browsing the branch knows
129+
# which minor + source ref produced it.
130+
cat > .archive-info.txt <<EOF
131+
minor_label: $MINOR_LABEL
132+
source_ref: $SOURCE_REF
133+
built_at: $(date -u +"%Y-%m-%dT%H:%M:%SZ")
134+
built_by: docs-archive-orphan-branch.yml (gh actions run $RUN_ID)
135+
EOF
136+
137+
- name: Configure git
138+
run: |
139+
git config --global user.name "github-actions[bot]"
140+
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
141+
142+
- name: Create orphan branch and commit artifacts
143+
if: ${{ inputs.dry_run != true }}
144+
env:
145+
MINOR_LABEL: ${{ inputs.minor_label }}
146+
SOURCE_REF: ${{ inputs.source_ref }}
147+
run: |
148+
BRANCH="docs-archive/$MINOR_LABEL"
149+
# Use a fresh worktree so we don't disturb the main checkout.
150+
git worktree add --detach /tmp/orphan-worktree
151+
cd /tmp/orphan-worktree
152+
# Detach HEAD and clear the index so the orphan starts truly empty.
153+
git checkout --orphan "$BRANCH"
154+
git rm -rf --cached . > /dev/null 2>&1 || true
155+
# Wipe everything — we want the orphan tree to contain ONLY the
156+
# archived site, no inherited source files.
157+
find . -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} +
158+
# Copy the staged archive in.
159+
cp -R /tmp/docs-archive/. .
160+
git add -A
161+
git commit -m "docs-archive($MINOR_LABEL): build from $SOURCE_REF"
162+
# Force-push: each invocation replaces the previous archive for
163+
# this minor, since we re-build with the toolkit-of-its-day to
164+
# pick up the latest patch within the minor.
165+
git push --force origin "$BRANCH"
166+
167+
- name: Dry-run summary
168+
if: ${{ inputs.dry_run == true }}
169+
env:
170+
MINOR_LABEL: ${{ inputs.minor_label }}
171+
SOURCE_REF: ${{ inputs.source_ref }}
172+
run: |
173+
echo "Dry-run mode: orphan branch was NOT pushed."
174+
echo "Would push: docs-archive/$MINOR_LABEL"
175+
echo "Source ref: $SOURCE_REF"
176+
echo "Artifact size:"
177+
du -sh /tmp/docs-archive

packages/backend.ai-docs-toolkit/ARCHITECTURE.md

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,3 +161,146 @@ The `path` is relative to `src/{lang}/`.
161161
- **CSS delivery**: Currently inlined in `<style>` tag. Static website should use a shared `.css` file to avoid duplication across pages.
162162
- **Search index**: Must support CJK languages (Korean, Japanese, Thai). Bigram tokenization is effective for CJK. The index must work entirely client-side (no server, no CDN) for air-gapped deployments.
163163
- **Last updated date**: `git log -1 --format=%aI -- {filepath}` is the most accurate source. Fallback to `fs.statSync().mtime` for non-git environments. This data should be collected during build and embedded in each page.
164+
165+
## Versioned docs (F6 / FR-2718)
166+
167+
The toolkit supports an OPTIONAL minor-grained version model. When the
168+
optional `versions` key is present in `docs-toolkit.config.yaml`, the
169+
build emits a per-version directory layout. When `versions` is absent,
170+
the legacy flat layout is preserved unchanged (fully backward
171+
compatible).
172+
173+
### Version model
174+
175+
- The selector lists one row per **minor** version. Within a minor,
176+
the highest patch represents that minor (e.g., `25.16.5` is shown as
177+
`25.16`). This is enforced at config-load time: `versions.ts` rejects
178+
labels that match `/^\d+\.\d+\.\d+/`.
179+
- Eligibility is gated by the build itself: a minor is admitted to the
180+
selector only if its docs build cleanly under the current toolkit
181+
with `--strict` (F5). A minor that breaks the strict build must be
182+
patched, deferred via a follow-up issue, or explicitly excluded —
183+
the choice is documented in the PR that adds the entry.
184+
- Past-minor build artifacts live on **orphan branches**
185+
(`docs-archive/<minor>`). Past minors are built ONCE with the
186+
toolkit-of-its-day and pushed to the archive branch by the
187+
`docs-archive-orphan-branch.yml` workflow; they are NOT re-built with
188+
the current toolkit on every deploy.
189+
190+
### Output directory layout
191+
192+
| Mode | Page URL pattern | rootDepth |
193+
|---|---|---|
194+
| Flat (`versions` absent) | `dist/web/<lang>/<slug>.html` | 2 |
195+
| Versioned (`versions` declared) | `dist/web/<minor>/<lang>/<slug>.html` | 3 |
196+
197+
Site-shared assets always live at `dist/web/assets/...` (one set, content-hashed).
198+
The page builder derives a `../`-prefix from `rootDepth` so the same template
199+
works in both layouts.
200+
201+
In versioned mode, the root `dist/web/index.html` is a tiny meta-refresh
202+
that points at the latest version's default-language landing page. F1
203+
(language picker) may later replace this with a richer entry.
204+
205+
### `versions` schema
206+
207+
```yaml
208+
versions:
209+
- label: "25.16" # minor label only — never include patch
210+
source:
211+
kind: workspace # build from current checkout
212+
latest: true # exactly one entry must carry latest:true
213+
- label: "25.10"
214+
source:
215+
kind: archive-branch
216+
ref: docs-archive/25.10 # orphan branch holding pre-built artifacts
217+
```
218+
219+
Validation rules (enforced by `loadVersions` in `versions.ts`):
220+
221+
- `versions` is OPTIONAL. When omitted (or empty), the build runs in
222+
single-version compatibility mode and emits the flat layout.
223+
- `versions[].label` must be a non-empty string of the shape
224+
`MAJOR.MINOR`. Three-segment "patch-shaped" labels are rejected.
225+
- `versions[].source.kind` must be `workspace` or `archive-branch`.
226+
- For `archive-branch`, `versions[].source.ref` is required.
227+
- Exactly **one** entry must carry `latest: true`.
228+
- Labels must be unique within `versions[]`.
229+
230+
### Source kinds
231+
232+
- **`workspace`** — build from the current checkout. Used for the
233+
`latest` entry in normal day-to-day deploys.
234+
- **`archive-branch`** — build by reading from a sibling worktree at
235+
`<projectRoot>/.docs-archive/<sanitized-ref>`. The CI workflow that
236+
PUSHES the orphan branch ships in this PR
237+
(`.github/workflows/docs-archive-orphan-branch.yml`); the workflow /
238+
operator that PULLS the orphan branch into a worktree before rebuild
239+
is operational scope. If the worktree does not exist when the build
240+
runs, the version is logged and skipped (NOT a hard failure) so CI
241+
doesn't break before the archive infrastructure is materialized.
242+
243+
### Header version selector
244+
245+
Every page in versioned mode emits a `<header class="page-header-bar">`
246+
with a `<select class="version-switcher">` listing every minor label.
247+
On change, an inline JS handler navigates to the same slug in the
248+
target version. If the slug doesn't exist there, the browser hits a
249+
404 → operators serve an index page redirect, OR (preferred) the
250+
build's per-version slug-availability map (recorded in
251+
`VersionPageRegistry`) drives the link to the version's `index.html`
252+
directly.
253+
254+
The dropdown lists minors only — patches are never shown. The data
255+
needed by the inline script is embedded as `data-` attributes on the
256+
`<select>`, keeping the script body tiny (well under the 25 KB
257+
per-page JS budget).
258+
259+
### Version scope isolation
260+
261+
- **Sidebar / right-rail TOC / internal links** operate within the
262+
current version only — the markdown processor reads only the
263+
current version's `book.config.yaml` and `<lang>/...` tree.
264+
- **Search index** is partitioned per `(version, lang)` and lives at
265+
`dist/web/<version>/<lang>/search-index.json`. Search results never
266+
leak across versions.
267+
- **canonical URL** (built by F2): always points at the same slug in
268+
the latest version (`dist/web/<latest-label>/<lang>/<slug>.html`).
269+
`canonicalPathFor()` in `versions.ts` returns the canonical relative
270+
path; F2 wraps it in the absolute base URL.
271+
- **hreflang** (built by F1): cross-links languages WITHIN the same
272+
version, never across versions.
273+
274+
### API surface for F2 (sitemap + canonical)
275+
276+
`generateWebsite()` returns a `GenerateWebsiteResult` containing:
277+
278+
```typescript
279+
{
280+
versioned: boolean; // false in flat mode
281+
pages: PageEnumerationRow[]; // one row per (version, lang, slug)
282+
versionsBuilt: Version[]; // entries that actually rendered
283+
}
284+
```
285+
286+
Each `PageEnumerationRow` has `{ version, lang, slug, path, isLatest }`.
287+
F2 iterates `pages` to emit `sitemap.xml`. `canonicalPathFor(loaded, lang, slug)`
288+
returns the per-page canonical URL relative to the website output root.
289+
290+
### Single-version compatibility mode
291+
292+
When `versions` is not declared, the build behaves exactly as before
293+
F6: flat `dist/web/<lang>/...` layout, no version selector, no
294+
per-version subdirs, no root redirect. This is the default path in
295+
`backend.ai-webui-docs` today; the operator opts into versioned mode
296+
by adding `versions:` to `docs-toolkit.config.yaml`.
297+
298+
### PDF pipeline interaction
299+
300+
The PDF pipeline (`generate-pdf.ts`) reads ONLY `book.config.yaml`,
301+
not the `versions` key in `docs-toolkit.config.yaml`. Adding a
302+
`versions` block has zero effect on PDF output: `pnpm run pdf:en`
303+
continues to build the current checkout's content, regardless of
304+
whether `versions` is declared. This is intentional — past-minor PDFs
305+
are likewise produced by their toolkit-of-its-day, not by re-rendering
306+
through the current toolkit.

packages/backend.ai-docs-toolkit/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"scripts": {
3939
"build": "tsc",
4040
"dev": "tsc --watch",
41+
"test": "tsx --test src/versions.test.ts",
4142
"prepublishOnly": "pnpm build"
4243
},
4344
"dependencies": {

0 commit comments

Comments
 (0)