docs-s3: assemble a versioned documentation site (#347) - #561
Merged
Conversation
… 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
This was referenced Aug 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #347 (
docs-s3) — ADR-0009.GitHub Actions assembles a published tree onto a
docs-publishedbranch that the hosting app serves prebuilt: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 nogit 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.ymldoes 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/aloneNo 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.
latestisnull, the root redirect points atdev/, and the dropdown hides itself rather than offering a choice of one. Rehearsed end to end locally:latestis the highest semver, not the most recent tagRehearsed with four versions published in tag order, ending with a patch on an old line:
latestbecomesdevv0.1.0v0.1.0v0.10.0v0.10.0— beatsv0.9.xnumericallyv0.9.1(tagged last)v0.10.0, unmovedDropdown order came out
v0.10.0 | v0.9.1 | v0.1.0 | dev (main). Thev0.9.1run 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/, labelledDRY RUN … — scratch prefix, publishes nothingin the Actions list and in the run summary.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.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 recomputedlatest, 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
HEADrequest 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. Insteadassemble.mjswrites each version's page list intoversions.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 ownSelect.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:/dev/reference/attributes/✅/reference/edit-nodes❌Astro applies
baseto 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.mjsgates 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.ymland the runbookamplify.ymldeclares empty build phases andbaseDirectory: /, andassemble.mjscopies 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
build:web --base=/dev/(40 pages, 2130 absolute references, all prefixed) andbuild:local(unchanged: no base, no dropdown, no absolute references).docs-publish.ymlresolves to a singlepushtrigger carrying bothbranchesandtags.main— 13 findings on both sides, so this adds none.Sabotages — three, each verified present in the source first
check-web-buildfails, naming the bare hrefslatest= most recent instead of highest semverOut 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
docs-publishedbranch and attach the domain (§3 of the issue: every AWS action is the maintainer's). The runbook is the deliverable that unblocks it.The branch itself is created by the first workflow run, not by hand.