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
190 changes: 190 additions & 0 deletions .github/workflows/docs-publish.yml
Original file line number Diff line number Diff line change
@@ -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
21 changes: 8 additions & 13 deletions .github/workflows/docs-site.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 29 additions & 1 deletion docs-site/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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=/<segment>/`, 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`
Expand Down
15 changes: 15 additions & 0 deletions docs-site/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <script>, and the offline copy
// has exactly one version by definition — there is nothing to switch to.
components: local ? {} : { LanguageSelect: './src/components/VersionSelect.astro' },
sidebar: [
{ label: 'Guide', link: '/' },
{ label: 'Reference', autogenerate: { directory: 'reference' } },
Expand Down
2 changes: 2 additions & 0 deletions docs-site/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
"theme": "node scripts/theme-css.mjs",
"build": "npm run theme && npm run adapt && node scripts/check-f1-coverage.mjs && astro build",
"build:local": "node scripts/build-local.mjs",
"build:web": "node scripts/build-web.mjs",
"assemble": "node scripts/assemble.mjs",
"dev": "npm run theme && npm run adapt && astro dev",
"licenses": "node scripts/licenses.mjs",
"check": "node scripts/check-f1-coverage.mjs",
Expand Down
26 changes: 26 additions & 0 deletions docs-site/publish/amplify.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Serve-prebuilt configuration for the published documentation tree.
#
# This file rides ON the publishing branch (scripts/assemble.mjs copies it into
# the assembled root), because that is the branch the hosting app is connected
# to and where it looks for its build settings.
#
# THERE IS NO BUILD PHASE, AND THAT IS THE DESIGN (ADR-0009). The tree is
# assembled by GitHub Actions — several versions, a computed `latest/`, a root
# redirect and a versions manifest — and the host only serves the result.
# Building from source here would move that multi-version logic into a console
# UI where it could not be reviewed, tested, or rolled back with the code.
#
# `baseDirectory: /` therefore means "publish the branch as it stands".
version: 1
frontend:
phases:
preBuild:
commands: []
build:
commands: []
artifacts:
baseDirectory: /
files:
- '**/*'
cache:
paths: []
Loading
Loading