Skip to content

docs-s3: assemble a versioned documentation site (#347) - #561

Merged
JArmandoAnaya merged 1 commit into
mainfrom
docs/347-versioned-publishing
Aug 2, 2026
Merged

docs-s3: assemble a versioned documentation site (#347)#561
JArmandoAnaya merged 1 commit into
mainfrom
docs/347-versioned-publishing

Conversation

@JArmandoAnaya

Copy link
Copy Markdown
Contributor

Closes #347 (docs-s3) — ADR-0009.

GitHub Actions assembles a published tree onto a docs-published branch that the hosting app serves prebuilt:

/                 redirect to latest/, or dev/ while no release exists
/versions.json    the manifest the header dropdown reads
/dev/             built from main
/vX.Y.Z/          built from each release tag
/latest/          a copy of the highest-semver version
/amplify.yml      the serve-prebuilt config, riding on the published branch

Nothing added here creates a tag or a release

The triggers are a push to main, a push of a tag that already exists, and a manual dry run. There is no git tag, no tag push, no release action anywhere in the workflow. Publishing stays the maintainer's decision (release philosophy §4).

To be precise rather than flattering: release.yml does publish a GitHub release, reacting to a tag the maintainer pushed. It predates this work and this PR does not modify it. What is true is that nothing this PR adds creates a tag or a release, and no workflow in the repository creates a tag.

It works today, with dev/ alone

No tags exist after the roadmap reset, so that is the state that has to work — and it is a supported state, not a half-built one. latest is null, the root redirect points at dev/, and the dropdown hides itself rather than offering a choice of one. Rehearsed end to end locally:

assemble: dev/, index.html -> dev/, versions.json, amplify.yml
assemble: 1 version(s), latest=(none yet), root -> dev/

latest is the highest semver, not the most recent tag

Rehearsed with four versions published in tag order, ending with a patch on an old line:

Published latest becomes
dev (none yet)
v0.1.0 v0.1.0
v0.10.0 v0.10.0 — beats v0.9.x numerically
v0.9.1 (tagged last) v0.10.0, unmoved

Dropdown order came out v0.10.0 | v0.9.1 | v0.1.0 | dev (main). The v0.9.1 run did not even rewrite the root redirect — assembly writes only changed bytes.

Assembly is idempotent and non-destructive: it replaces one segment and recomputes the derived files from whatever is on disk, so a version an individual run knows nothing about survives it. Both are tests, not claims.

The dry run cannot damage the live tree, two ways over

workflow_dispatch → a version → a complete tree under _dryrun/, labelled DRY RUN … — scratch prefix, publishes nothing in the Actions list and in the run summary.

  1. Structural. The assembler is handed the scratch directory as its root. It writes its own versions.json, latest/ and redirect in there; the real ones are not addressable from inside it. Containment by construction, not a list of paths to avoid.
  2. Verified. A following step asks git what changed and fails on anything outside the prefix — because a dry run that quietly republished dev/ would otherwise look exactly like a passing one.

Rehearsed locally: the live tree hashed byte-identical across a dry run that produced a full _dryrun/ tree with its own recomputed latest, and the live manifest never learned the candidate version existed.

The dropdown reads a page list; it does not probe the server

"Preserve the current page where it exists" needs to know whether it exists. A HEAD request would make that depend on how the host answers for a missing file — and a host with an SPA-style 200 fallback would return 200 for everything, so every switch would look successful while landing the reader on a not-found page that renders like a real one. Instead assemble.mjs writes each version's page list into versions.json: deterministic, no extra request, no assumption about the host.

It takes LanguageSelect's slot, which Starlight's header renders unconditionally and a single-language site leaves empty — a header position already shaped for choosing a variant of the site. It reuses Starlight's own Select.astro, so it matches the theme control beside it, and it is not registered at all for the offline reader (guarding inside the component would still ship its hoisted <script>; verified zero occurrences in the local build).

The defect found on the way — measured, not reasoned

Before designing anything I built with --base=/dev/ and read the output:

  • Starlight's nav → /dev/reference/attributes/
  • the adapter's content link → /reference/edit-nodes

Astro applies base to the links it generates and passes content through untouched. So a versioned build has a perfect sidebar and a dead link in every paragraph — precisely the failure a spot check from the front page cannot see. The adapter now applies the same prefix from the same environment variable the config reads, so the two cannot disagree.

check-web-build.mjs gates it, and PR CI now builds under /dev/ instead of / — a root build cannot exercise the base at all, so the gate would otherwise have been decorative.

amplify.yml and the runbook

amplify.yml declares empty build phases and baseDirectory: /, and assemble.mjs copies it into the assembled root so it rides on the branch the app is connected to. There is no build phase by design (ADR-0009): building from source there would move multi-version assembly into a console UI where it could not be reviewed, tested, or rolled back with the code.

docs/contributing/docs-site-publishing.md is written for the maintainer to follow without reading any YAML: one-time app setup (including "decline the framework detection"), the everyday flow, how to rehearse a release, a five-point deploy verification, and a per-step failure guide.

Verification

  • 17/17 script tests (13 new for the assembler), covering semver ordering, the dev-only tree, release recomputation, non-destructive rebuilds, idempotence, dry-run containment, and the refusals.
  • Both builds green — build:web --base=/dev/ (40 pages, 2130 absolute references, all prefixed) and build:local (unchanged: no base, no dropdown, no absolute references).
  • Licence gate clean — 374 packages, all permissive, no dependency added, sharp stub untouched.
  • All four workflow files parse; docs-publish.yml resolves to a single push trigger carrying both branches and tags.
  • Link check as a differential against an extracted main — 13 findings on both sides, so this adds none.
  • No C++ or CMake touched.

Sabotages — three, each verified present in the source first

Sabotage Result
adapter stops prefixing content links check-web-build fails, naming the bare hrefs
latest = most recent instead of highest semver assemble tests fail (16/17)
dry run writes to the real root containment tests fail (15/17)

Out of scope, respected

No AWS or hosting resources were created, configured or authenticated, and no credentials were added — the sprint delivers amplify.yml, the workflows and the runbook, exactly as the issue scopes it. No tag, no release, no version of RoadMaker. The sharp/LGPL posture is untouched and no dependency was added. C++ jobs untouched; site CI stays Linux-only. No analytics or third-party scripts. Page structure untouched (docs-s1); the local reader and packaging are docs-s2, already merged. #297 untouched.

Outstanding — requires the maintainer

  • Create the hosting app against the docs-published branch and attach the domain (§3 of the issue: every AWS action is the maintainer's). The runbook is the deliverable that unblocks it.
  • Confirm the first live deploy against the runbook's five-point checklist — in particular following a link in body text rather than in the sidebar, which is the check the base defect would have escaped.

The branch itself is created by the first workflow run, not by hand.

… tag path before there is a tag (p-docs-s3)

GitHub Actions now 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 the hosting app serves prebuilt.

NOTHING ADDED HERE CREATES A TAG OR A RELEASE. The workflow reacts to a tag the
maintainer has already pushed: its triggers are a push to main, a push of an
existing tag, and a manual dry run. (release.yml's own release step predates
this and is not touched.)

latest/ follows the highest semver rather than the most recent tag, so tagging a
patch on 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, which is the property that makes rebuilding dev
safe once releases exist.

The whole pipeline works with dev/ alone, which is today's state and is a
supported one rather than a half-built one: latest is null, the root redirect
points at dev/, and the dropdown hides itself rather than offering a choice of
one.

The workflow_dispatch dry run rehearses the tag path 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 it writes a complete tree of its own in there
and the real dev/, latest/, versions.json and redirect are not addressable from
inside it — containment by construction rather than a list of paths to avoid.
Then a step asks git what actually changed and fails on anything outside the
prefix, because a dry run that quietly republished dev/ would otherwise look
exactly like a passing one.

The 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: a HEAD
request would make correctness depend on how the host answers for a missing
file, and a host with an SPA-style 200 fallback would make every switch look
successful while landing the reader on a not-found page that renders like a real
one. It takes LanguageSelect's header slot, which Starlight renders
unconditionally and a single-language site leaves empty, and is not registered
at all for the offline reader.

One defect fixed on the way, found by building with a base and reading the
output rather than by reasoning about it: Astro applies `base` to the links IT
generates, but a link written in the guide's Markdown is content and passes
through untouched. Under a version segment the sidebar and nav worked while
every in-content cross-page link 404ed — the failure a spot check from the front
page cannot see. The adapter now applies the same prefix from the same
environment variable the Astro config reads, check-web-build.mjs fails a build
where any root-absolute reference is missing it, and PR CI builds under /dev/
rather than / so that gate is not decorative.

Closes #347
@JArmandoAnaya
JArmandoAnaya merged commit 30211cc into main Aug 2, 2026
18 checks passed
@JArmandoAnaya
JArmandoAnaya deleted the docs/347-versioned-publishing branch August 2, 2026 14:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

docs-s3: versioned web publishing

1 participant