diff --git a/.github/workflows/docs-publish.yml b/.github/workflows/docs-publish.yml new file mode 100644 index 00000000..916f7431 --- /dev/null +++ b/.github/workflows/docs-publish.yml @@ -0,0 +1,190 @@ +# Assembles the published documentation tree onto the `docs-published` branch +# (ADR-0009 / docs-s3). Linux-only and Node-only, like docs-site.yml; nothing +# here touches the C++/CMake matrix. +# +# THIS WORKFLOW NEVER CREATES A TAG OR A RELEASE. +# Read the triggers: a push to main, a push of a tag that ALREADY EXISTS, and a +# manual dry run. There is no `gh release create`, no `git tag`, no tag push, no +# release action. Only the maintainer publishes (docs/roadmap/README.md, release +# philosophy §4) and this only ever reacts. +# +# Hosting is the maintainer's: the app is connected to `docs-published` and +# serves it prebuilt. See docs/contributing/docs-site-publishing.md. +name: docs publish + +on: + push: + # No `paths` filter, deliberately. A push filter applies to tag pushes too, + # so filtering on docs paths would skip publishing a release whose tag + # happens to carry no documentation change — the one case that must never be + # skipped. Rebuilding `dev` on every push to main is the cheaper mistake: + # the assembler writes only changed bytes and the commit step exits early + # when the tree is identical. + branches: [main] + tags: ['v*'] + workflow_dispatch: + inputs: + version: + description: 'DRY RUN ONLY — version segment to rehearse, e.g. v0.1.0' + required: true + default: 'v0.1.0' + +permissions: + contents: write + +concurrency: + # One writer at a time. Two runs assembling the same branch would race on the + # push and the loser's version directory would vanish without a failure. + group: docs-publish + cancel-in-progress: false + +jobs: + assemble: + name: >- + ${{ github.event_name == 'workflow_dispatch' + && format('DRY RUN {0} — scratch prefix, publishes nothing', inputs.version) + || (startsWith(github.ref, 'refs/tags/') + && format('publish {0}', github.ref_name) + || 'publish dev') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Decide the segment and where it goes + id: plan + shell: bash + run: | + set -euo pipefail + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + # A dry run assembles a COMPLETE tree of its own under a scratch + # prefix — its own versions.json, latest/ and root redirect. It + # cannot reach the real ones because from inside the scratch root + # they are not addressable: containment by construction rather than + # by remembering to be careful. + segment='${{ inputs.version }}' + scratch='_dryrun' + base="/$scratch/$segment/" + else + if [[ "${GITHUB_REF}" == refs/tags/* ]]; then + segment="${GITHUB_REF_NAME}" + else + segment='dev' + fi + scratch='' + base="/$segment/" + fi + + if [[ ! "$segment" =~ ^(dev|v[0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + echo "::error::'$segment' is neither 'dev' nor vMAJOR.MINOR.PATCH" + exit 1 + fi + + { + echo "segment=$segment" + echo "scratch=$scratch" + echo "base=$base" + } >> "$GITHUB_OUTPUT" + + { + if [[ -n "$scratch" ]]; then + echo "### DRY RUN — rehearsing \`$segment\`" + echo "" + echo "Writes **only** under \`$scratch/\`. The live \`dev/\`, \`latest/\`," + echo "\`versions.json\` and root redirect are untouched, and a step below" + echo "proves that against git rather than trusting it." + else + echo "### Publishing \`$segment\`" + fi + echo "" + echo "- base: \`$base\`" + } >> "$GITHUB_STEP_SUMMARY" + + - uses: actions/setup-node@v4 + with: + node-version-file: docs-site/.nvmrc + cache: npm + cache-dependency-path: docs-site/package-lock.json + + - name: Install + working-directory: docs-site + run: npm ci + + - name: Licence gate + working-directory: docs-site + run: npm run licenses + + # Ends in check-web-build.mjs, which fails if any root-absolute reference + # is missing the segment prefix — the failure that leaves the sidebar + # working and every in-content link dead. + - name: Build the site for this segment + working-directory: docs-site + run: npm run build:web -- --base=${{ steps.plan.outputs.base }} + + # A second checkout so the publishing branch never shares a working tree + # with the source. actions/checkout persists the token, which is what lets + # the push at the end authenticate. + - uses: actions/checkout@v4 + with: + path: published + + - name: Prepare the publishing branch + working-directory: published + run: | + set -euo pipefail + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + if git ls-remote --exit-code --heads origin docs-published > /dev/null 2>&1; then + git fetch --depth 1 origin docs-published + git checkout -B docs-published origin/docs-published + else + # First run: start the branch with no history of the source tree. + echo "docs-published does not exist yet — creating it" + git checkout --orphan docs-published + git rm -rq --cached . || true + find . -mindepth 1 -maxdepth 1 -not -name .git -exec rm -rf {} + + fi + + - name: Assemble + run: | + set -euo pipefail + node docs-site/scripts/assemble.mjs \ + --root=published \ + --segment='${{ steps.plan.outputs.segment }}' \ + --build=docs-site/dist \ + --scratch='${{ steps.plan.outputs.scratch }}' | tee assemble.log + { + echo '' + echo '```' + cat assemble.log + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Verify the dry run stayed inside its scratch prefix + if: github.event_name == 'workflow_dispatch' + working-directory: published + run: | + set -euo pipefail + # Independent of the assembler's own containment: ask git what actually + # changed. A dry run that quietly republished dev/ would otherwise look + # exactly like a pass. + outside=$(git status --porcelain -- . ':(exclude)_dryrun' || true) + if [[ -n "$outside" ]]; then + echo '::error::the dry run modified paths outside _dryrun/:' + echo "$outside" + exit 1 + fi + echo 'dry run touched only _dryrun/ — verified against git, not assumed' + + - name: Commit and push + working-directory: published + run: | + set -euo pipefail + git add -A + if git diff --cached --quiet; then + echo 'nothing to publish — the assembled tree is byte-identical' + exit 0 + fi + kind='publish' + if [[ -n '${{ steps.plan.outputs.scratch }}' ]]; then kind='dry run'; fi + git commit -m "docs: $kind ${{ steps.plan.outputs.segment }} (${GITHUB_SHA:0:7})" + git push origin HEAD:docs-published diff --git a/.github/workflows/docs-site.yml b/.github/workflows/docs-site.yml index 7024aedd..d95be814 100644 --- a/.github/workflows/docs-site.yml +++ b/.github/workflows/docs-site.yml @@ -48,22 +48,17 @@ jobs: - name: Licence gate run: npm run licenses - - name: Theme tokens (from theme.cpp) - run: npm run theme - - - name: Adapt docs/user-guide - run: npm run adapt - - # Every F1-reachable page must exist on the site too, so a page can never - # be reachable in-app but missing here. - - name: F1 coverage - run: npm run check - - name: Script tests run: npm test - - name: Build - run: npx astro build + # Built under a version segment rather than at the root, because that is + # how it is actually published and because a root build cannot exercise + # the base at all: Astro prefixes the links IT generates, so a missing + # prefix on the links written in the guide's Markdown is invisible until + # something is served from `/dev/`. build:web runs theme -> adapt -> F1 + # coverage -> astro build -> the base check, in that order. + - name: Build (under a version segment, as published) + run: npm run build:web -- --base=/dev/ - uses: actions/upload-artifact@v4 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index c466548a..3810cb86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Current version on `main`: **0.0.1**. ### Added +- **The documentation site publishes itself, versioned** + ([#347](https://github.com/Robomous/RoadMaker/issues/347), docs-s3 — + [ADR-0009](docs/decisions/0009-documentation-site-tiered-docs.md)). GitHub + Actions assembles a published tree — `dev/` from `main`, `vX.Y.Z/` from each + release tag, a `latest/` copy of the highest version, a root redirect and a + `versions.json` — onto a `docs-published` branch that the hosting app serves + prebuilt. Runbook: + [Publishing the documentation site](docs/contributing/docs-site-publishing.md). + + **Nothing added here creates a tag or a release.** The workflow reacts to a tag + the maintainer has already pushed; publishing stays their decision + ([release philosophy](docs/roadmap/README.md#release-philosophy)). + + `latest/` follows the **highest semver, not the most recent tag**, so patching + an old line after a newer minor exists does not drag `latest` backwards. + Assembly is idempotent and replaces one version directory at a time, + recomputing the derived files from whatever is on the branch — a version an + individual run knows nothing about survives it. + + The whole pipeline works with `dev/` alone, which is today's state: `latest` is + `null`, the root redirect points at `dev/`, and the version dropdown hides + itself rather than offering a choice of one. + + A `workflow_dispatch` **dry run** rehearses the tag-driven path into a scratch + prefix before any real tag exists. It cannot damage the live tree two ways + over: the assembler is handed the scratch directory as its root, so the real + `dev/`, `latest/`, `versions.json` and redirect are not addressable from + inside it; and a following step asks git what changed and fails if anything + outside the prefix did. + + The version dropdown preserves the reader's current page where the target + version has it and falls back to that version's landing page where it does + not. It reads each version's page list out of `versions.json` rather than + probing the server, because a host that answers a missing file with a 200 + fallback would make a broken switch look like a working one. + + One defect fixed on the way: Astro applies its `base` to the links it + generates, but a link written in the guide's Markdown is content and passed + through untouched — so under a version segment the sidebar and nav worked + while every in-content cross-page link 404ed. The adapter now applies the same + prefix, and `check-web-build.mjs` fails a build where any root-absolute + reference is missing it. - **The manual ships with the app, and reference pages bridge into it** ([#346](https://github.com/Robomous/RoadMaker/issues/346), docs-s2 — [ADR-0009](docs/decisions/0009-documentation-site-tiered-docs.md)). Every diff --git a/docs-site/README.md b/docs-site/README.md index c23c1083..5a8da1ec 100644 --- a/docs-site/README.md +++ b/docs-site/README.md @@ -35,6 +35,7 @@ Tiers ([ADR-0009](../docs/decisions/0009-documentation-site-tiered-docs.md)): npm ci npm run build # theme -> adapt -> F1 coverage -> astro build npm run build:local # the offline reader that ships in a release +npm run build:web -- --base=/dev/ # the published site, for one version npm run dev # same as build, then a dev server npm run licenses # licence gate over the installed tree npm test # script tests (node:test) @@ -47,7 +48,7 @@ target. | Build | Output | Search | Links | |---|---|---|---| -| `build` (web) | directory URLs | Pagefind | root-absolute | +| `build:web` | directory URLs, under `--base` | Pagefind | root-absolute, segment-prefixed | | `build:local` | `format: 'file'` | **off** | fully relative | `build:local` produces the copy bundled in every release, which a reader opens @@ -73,6 +74,33 @@ A maintained relative-links integration was considered and rejected: every npm package here is a permanent obligation under the licence gate, and this transform is string work over a directory of HTML. +## Versioned publishing + +`build:web` takes a `--base=//`, because each published version is a +path segment (`/dev/`, `/v0.1.0/`). The base reaches **two** consumers through +one environment variable, and that is not incidental: Astro prefixes the links +*it* generates — sidebar, nav, assets — but a link written in the guide's +Markdown is content and passes through untouched, so `adapt.mjs` has to apply +the same prefix. Getting that wrong leaves the sidebar working perfectly and +every in-content link dead, which is why `check-web-build.mjs` gates it. + +`scripts/assemble.mjs` builds the published tree — version directories, a +`latest/` copy of the **highest semver** (not the most recent tag), a root +redirect, and the `versions.json` the header dropdown reads. It is idempotent +and non-destructive: it replaces one segment and recomputes the derived files +from whatever is on disk, so a version it knows nothing about survives. + +`versions.json` carries each version's **page list**, so the dropdown preserves +the reader's current page without probing the server — a host that answers a +missing file with a 200 fallback would otherwise make a broken switch look fine. + +The whole thing works with `dev/` alone, which is today's state: `latest` is +`null`, the root redirect points at `dev/`, and the dropdown hides itself rather +than offering a choice of one. + +Maintainer runbook, including the dry run: +[Publishing the documentation site](../docs/contributing/docs-site-publishing.md). + ## The reference → guide bridge A reference page may end with a section under the exact heading `## Full guide` diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index 782e48c6..2c28a3fc 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -15,7 +15,14 @@ import starlight from '@astrojs/starlight'; // turns the root-absolute refs Astro emits into relative ones. const local = process.env.RM_DOCS_TARGET === 'local'; +// Published under a version segment (`/dev/`, `/v0.1.0/`, or a dry-run scratch +// prefix) — docs-s3. Always a leading and trailing slash so it composes by +// concatenation; `/` for a site served from the domain root, and for the local +// reader, which has no server and no site root at all. +const base = local ? '/' : (process.env.RM_DOCS_BASE ?? '/'); + export default defineConfig({ + base, // Astro's default image service is `sharp`, whose prebuilt libvips binaries // are LGPL-3.0-or-later. Qt is this project's ONLY sanctioned LGPL dependency // (docs/standards/dependencies.md), so the passthrough service is used and @@ -36,6 +43,14 @@ export default defineConfig({ // Never ship a search box that does nothing: switching Pagefind off also // removes the header UI that would query it. pagefind: !local, + // The version dropdown takes LanguageSelect's slot: the header renders it + // unconditionally and a single-language site leaves it empty, so it is a + // header position already shaped for choosing a variant of the site. + // + // Not registered at all for the local reader. Guarding inside the + // component would still ship its hoisted + + +`; +} + +/** Copy a directory over the top of a destination, replacing it wholesale. */ +function replaceDir(src, dest) { + rmSync(dest, { recursive: true, force: true }); + mkdirSync(dirname(dest), { recursive: true }); + cpSync(src, dest, { recursive: true }); +} + +/** Write only when the bytes differ, so an unchanged run reports nothing changed. */ +function writeIfChanged(path, content) { + if (existsSync(path) && readFileSync(path, 'utf8') === content) return false; + writeFileSync(path, content); + return true; +} + +/** + * Place `buildDir` at `segment` inside `publishRoot` and recompute the derived + * files. + * + * `scratch` is the dry run: everything, including that run's own versions.json, + * latest/ and root redirect, is written inside `publishRoot//`. That is + * a containment property rather than a promise — the real dev/, latest/, + * versions.json and root redirect are not addressable from inside the scratch + * root at all, so a dry run cannot reach them even if this code is wrong. + */ +export function assemble({ publishRoot, buildDir, segment, scratch = '', rootPath = '/' }) { + if (!SEGMENT_RE.test(segment)) { + throw new Error( + `refusing to publish segment '${segment}': expected 'dev' or 'vMAJOR.MINOR.PATCH'`, + ); + } + if (buildDir && !existsSync(join(buildDir, 'index.html'))) { + throw new Error(`'${buildDir}' has no index.html — that is not a built site`); + } + + const root = scratch ? join(publishRoot, scratch) : publishRoot; + const effectiveRootPath = scratch ? `${rootPath}${scratch}/`.replace(/\/+/g, '/') : rootPath; + mkdirSync(root, { recursive: true }); + + const changed = []; + + if (buildDir) { + replaceDir(buildDir, join(root, segment)); + changed.push(`${segment}/`); + } + + // latest/ is a COPY, not a link: the host serves files, and a symlink in a git + // tree is a file containing a path. + const manifest = buildVersionsManifest(root, effectiveRootPath); + if (manifest.latest) { + replaceDir(join(root, manifest.latest), join(root, 'latest')); + changed.push(`latest/ (= ${manifest.latest})`); + } + + if (manifest.default) { + if (writeIfChanged(join(root, 'index.html'), buildRootRedirect(manifest.default))) { + changed.push(`index.html -> ${manifest.default}/`); + } + } + + if (writeIfChanged(join(root, 'versions.json'), `${JSON.stringify(manifest, null, 2)}\n`)) { + changed.push('versions.json'); + } + + const amplify = join(dirname(fileURLToPath(import.meta.url)), '..', 'publish', 'amplify.yml'); + if (existsSync(amplify)) { + if (writeIfChanged(join(root, 'amplify.yml'), readFileSync(amplify, 'utf8'))) { + changed.push('amplify.yml'); + } + } + + return { root, manifest, changed }; +} + +// ------------------------------------------------------------------ as a script + +function arg(name, fallback = '') { + const prefix = `--${name}=`; + const found = process.argv.find((a) => a.startsWith(prefix)); + return found ? found.slice(prefix.length) : fallback; +} + +const here = fileURLToPath(import.meta.url); +if (process.argv[1] && join(process.argv[1]) === here) { + const publishRoot = arg('root'); + const segment = arg('segment'); + if (!publishRoot || !segment) { + console.error( + 'usage: assemble.mjs --root= --segment=dev|vX.Y.Z [--build=] [--scratch=]', + ); + process.exit(1); + } + try { + const { manifest, changed } = assemble({ + publishRoot, + buildDir: arg('build'), + segment, + scratch: arg('scratch'), + }); + console.log(`assemble: ${changed.length > 0 ? changed.join(', ') : 'nothing changed'}`); + console.log( + `assemble: ${manifest.versions.length} version(s), latest=${manifest.latest ?? '(none yet)'}, root -> ${manifest.default}/`, + ); + } catch (error) { + console.error(`assemble: ${error.message}`); + process.exit(1); + } +} diff --git a/docs-site/scripts/build-web.mjs b/docs-site/scripts/build-web.mjs new file mode 100644 index 00000000..39067c5c --- /dev/null +++ b/docs-site/scripts/build-web.mjs @@ -0,0 +1,64 @@ +// Copyright 2026 Robomous +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// `npm run build:web -- --base=/dev/` — the published site, for one version +// segment (ADR-0009 / docs-s3). Search on, directory URLs, and every link +// prefixed with the segment. +// +// The base has to reach BOTH the Astro config (which prefixes the links Astro +// generates) and the adapter (which prefixes the links written in the guide's +// Markdown — content Astro passes through untouched). Passing it through one +// environment variable is what keeps those two from disagreeing. +import { spawnSync } from 'node:child_process'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const root = join(here, '..'); + +const raw = + process.argv.find((a) => a.startsWith('--base='))?.slice('--base='.length) ?? + process.env.RM_DOCS_BASE ?? + '/'; +// Normalise to leading + trailing slash so the two consumers compose it by +// plain concatenation and cannot differ over a missing separator. +const base = `/${raw.replace(/^\/+|\/+$/g, '')}/`.replace('//', '/'); + +const env = { ...process.env, RM_DOCS_TARGET: 'web', RM_DOCS_BASE: base }; + +function step(label, args) { + console.log(`\nbuild:web (${base}) — ${label}`); + const result = spawnSync(process.execPath, args, { + cwd: root, + env, + stdio: 'inherit', + shell: false, + }); + if (result.error) { + console.error(`build:web: ${label} could not start: ${result.error.message}`); + process.exit(1); + } + if (result.status !== 0) { + console.error(`build:web: ${label} failed (exit ${result.status})`); + process.exit(result.status ?? 1); + } +} + +step('theme tokens', [join(here, 'theme-css.mjs')]); +step('adapt docs/user-guide', [join(here, 'adapt.mjs')]); +step('F1 coverage', [join(here, 'check-f1-coverage.mjs')]); +step('astro build', [join(root, 'node_modules', 'astro', 'astro.js'), 'build']); +step('verify the base', [join(here, 'check-web-build.mjs')]); + +console.log(`\nbuild:web: dist/ is ready to publish under ${base}`); diff --git a/docs-site/scripts/check-web-build.mjs b/docs-site/scripts/check-web-build.mjs new file mode 100644 index 00000000..a925f1fe --- /dev/null +++ b/docs-site/scripts/check-web-build.mjs @@ -0,0 +1,70 @@ +// Copyright 2026 Robomous +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// The gate on a versioned web build (docs-s3): every root-absolute reference +// must carry the version segment. +// +// This exists because the failure it catches is close to invisible. Astro +// prefixes the links IT generates, so the sidebar and the nav are correct and +// the site looks fine; only the links written in the guide's own Markdown are +// left bare, and those 404 the moment a reader follows one from inside a page. +// A build under `/dev/` that emits `/reference/junction` is broken in exactly +// the places a spot check does not look. +import { readFileSync, existsSync, statSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { htmlFiles } from './relativize.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const distDir = process.argv[2] ?? join(here, '..', 'dist'); +const base = process.env.RM_DOCS_BASE ?? '/'; + +if (!existsSync(distDir) || !statSync(distDir).isDirectory()) { + console.error(`check-web-build: no build at ${distDir}`); + process.exit(1); +} + +const pages = htmlFiles(distDir); +if (pages.length === 0) { + console.error('check-web-build: the build contains no HTML pages at all'); + process.exit(1); +} + +const failures = []; +let checked = 0; + +for (const pageRel of pages) { + const html = readFileSync(join(distDir, pageRel), 'utf8'); + for (const match of html.matchAll(/\b(href|src|srcset)="([^"]*)"/g)) { + const [, attr, value] = match; + const urls = attr === 'srcset' ? value.split(',').map((p) => p.trim().split(/\s+/)[0]) : [value]; + for (const url of urls) { + // `//host/…` is protocol-relative, i.e. genuinely off-site. + if (!url.startsWith('/') || url.startsWith('//')) continue; + checked += 1; + if (!url.startsWith(base)) { + if (failures.length < 20) failures.push(`${pageRel}: ${attr}="${url}" is missing the ${base} prefix`); + else if (failures.length === 20) failures.push('…and more'); + } + } + } +} + +if (failures.length > 0) { + console.error(`check-web-build: root-absolute references outside ${base}`); + for (const failure of failures) console.error(` ${failure}`); + process.exit(1); +} +console.log(`check-web-build: ${pages.length} pages, ${checked} absolute references, all under ${base}`); diff --git a/docs-site/src/components/VersionSelect.astro b/docs-site/src/components/VersionSelect.astro new file mode 100644 index 00000000..51d9b272 --- /dev/null +++ b/docs-site/src/components/VersionSelect.astro @@ -0,0 +1,104 @@ +--- +// The header's version dropdown (ADR-0009 / docs-s3). +// +// It overrides Starlight's LanguageSelect, which the site's own Header renders +// unconditionally but which stays empty for a single-language site — so this is +// the one header slot already shaped for "pick a variant of this site", and +// using it needs no layout surgery. Select.astro is Starlight's own control, so +// the dropdown matches the theme selector beside it. +// +// The manifest lives at the PUBLISH ROOT, one level above this version's base, +// and both paths are known at build time — the component never has to guess +// where it is. +import Select from '@astrojs/starlight/components/Select.astro'; + +const base = import.meta.env.BASE_URL; +// `/dev/` -> `/`; `/_dryrun/v9.9.9/` -> `/_dryrun/`. A build served from the +// domain root has no version segment and therefore no sibling versions. +const root = base.replace(/[^/]+\/$/, ''); +const versioned = base !== '/'; +--- + +{ + versioned && ( + +