Skip to content

Publish the docs site per release with a version selector - #1199

Merged
Alexandre Zollinger Chohfi (azchohfi) merged 4 commits into
mainfrom
azchohfi-docs-version-selector
Sep 11, 2026
Merged

Alexandre Zollinger Chohfi (azchohfi) merged 4 commits into
mainfrom
azchohfi-docs-version-selector

Conversation

@azchohfi

@azchohfi Alexandre Zollinger Chohfi (azchohfi) commented Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

The docs site at https://microsoft.github.io/microsoft-ui-reactor/ was rebuilt from the tip of main on every docs push, so it only ever showed unreleased documentation. There was no way to read the docs matching the release you were actually running. This versions the site with mike plus Material for MkDocs' native version selector.

The model worth understanding before reviewing: gh-pages is a store of rendered HTML, one directory per version, not something derived from tags. A version exists on the site only because a build was run for it and the output filed there. That is what makes published versions stay byte-identical after they ship.

Trigger Result
Push to main (docs paths) Republishes main (development)
Push a v* tag Publishes that version, moves the latest alias, repoints the site root
Manual dispatch with backfill_tags Publishes older tags retroactively

The site root redirects to latest, so readers landing on the bare URL get the newest release rather than unreleased main. A tag only takes latest if it is the highest by version sort, so re-cutting or backporting an older tag cannot drag it backwards.

Versioning moves every page under a version directory, which would turn already published links (including the two in README.md) into hard 404s. The published site root therefore carries a 404.html that forwards legacy unversioned paths to the same page under latest, preserving the query string and anchor, and skipping paths already under a published version so it cannot loop.

Linked issue / spec

N/A. No issue or spec; this came from a direct request for a version selector on the docs site.

Test plan

  • dotnet test tests/Reactor.DocPipeline.Tests --filter-class "*SiteRootRedirectTests*" passes (succeeded: 1).
  • Mutation-checked that test through the real runner rather than trusting a green run: breaking the loop guard in 404.html turns it red (failed: 1), and the C# assertion surfaces the exact failing cases. Breaking the hash handling gives 9/10 and the fallback link 5/10 under node.
  • Rehearsed all three publish paths (main, release tag, backfill) locally against scratch branches with real mike 2.2.0, and checked the resulting versions.json, latest alias, root redirect and artifact assembly. latest/ and 0.1.0-preview.14/ resolve to the same git tree OID, so --alias-type copy costs no extra storage.
  • Confirmed an older tag builds clean under the new config: v0.1.0-preview.12 pins mkdocs-material 9.5.49 and passes mkdocs build --strict. This is what the backfill depends on.
  • Proved the new root-index guard both ways: a rootless site fails the assembly step with exit 1, a healthy one passes with exit 0.
  • python -m mkdocs build --strict clean, and verified docs/_site-root/ does not leak into the built site.

docs/_site-root/404.html is the one part of the published site MkDocs never parses, so mkdocs build --strict cannot cover it. Its cases live in docs/_site-root/404.redirect.test.js (runnable with plain node), and SiteRootRedirectTests in tests/Reactor.DocPipeline.Tests is what makes CI run them. That suite is already in the docs-build job, which is armed by any non-Markdown change, and both files qualify.

Risk / breaking changes

No product code and no public API change. The risks are all in the published site:

  • URL layout changes. Every page moves under a version directory. The root 404.html is the mitigation and is covered by the tests above. Worth a careful look, since it is the piece protecting existing links and search results.
  • A one-time manual step is needed after merge. Merging publishes only main. To seed the releases, dispatch Publish docs once with backfill_tags set to v0.1.0-preview.12 v0.1.0-preview.13 v0.1.0-preview.14. Until then the site root points at main rather than 404ing, but the dropdown will not look right.
  • Creates a new long-lived gh-pages branch. I checked that no other workflow, branch-glob trigger, or CODEOWNERS rule fires on pushes to it.
  • Permissions were narrowed, not widened: workflow scope is now permissions: {}, with contents: write only on the publish job and pages: write + id-token: write only on deploy.
  • The backfill overlays this branch's mkdocs.yml and theme override onto each old tag, since those tags predate the versioning config. That holds only while a tag's page set still satisfies the current nav; the per-tag mkdocs build --strict is what makes a tag that has drifted too far fail loudly instead of publishing broken.

Two commits, kept separate so the review history stays legible: the first is the feature, the second is exactly what a pr-review pass changed. That pass found a real one, confirmed by a reproduction against mike: mike deploy writes version directories and versions.json but never the root index.html, so a backfill omitting the newest tag would have published a site whose landing page 404s.

The site was rebuilt from the tip of main on every docs push, so it only ever
showed unreleased documentation and there was no way to read the docs for the
release you were actually running.

Version the site with mike and Material's native selector. Each version renders
once into the gh-pages branch and the Pages artifact is that whole branch, so a
version stays byte-identical after it ships. A release tag publishes its version
and takes the `latest` alias; main publishes separately as `main (development)`;
the site root redirects to `latest`. A tag only takes `latest` if it is the
highest by version sort, so re-cutting an older tag cannot drag it backwards.

Versioning moves every page under a version directory, which would turn already
published links -- including the two in README.md -- into hard 404s. Add a root
404.html forwarding legacy paths to the same page under `latest`, preserving the
query string and anchor, and skipping paths already under a published version so
it cannot loop.

Seed the existing releases by dispatching the workflow with backfill_tags; those
tags predate the versioning config, so the backfill layers this ref's mkdocs.yml
and theme override over each tag checkout, with mkdocs --strict as the gate that
makes a tag which has drifted too far fail loudly instead of publishing broken.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 93d332ff-21e7-4f39-b900-0997087233a4
A backfill that omitted the newest tag could publish a site with no root
index.html at all: `mike deploy` writes version directories and versions.json,
but only `set-default` writes the root, and the fallback chain stopped at an
echo when neither `latest` nor `main` existed. It now falls through to the
highest version the run published, and the artifact assembly asserts the root
exists rather than uploading a site whose landing page 404s.

The redirect that keeps already published links working shipped as JavaScript
that MkDocs never parses, so no gate could see it. Its cases now live next to
it and run under plain `node`, with an xUnit wrapper so CI runs them too; the
docs-build job is armed by any non-Markdown change, which both files are.

Also: validate backfill tags before publishing anything, so a typo fails the
run instead of leaving the site half-updated; reword the version banner, which
told readers of `main` they were viewing an older version when it is newer than
the latest release; fail rather than warn when the tracked 404.html is missing;
resolve the fallback link from the same computed base as the redirect; and
scope the runbook's claim about GitHub Pages to what the workflow controls.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 93d332ff-21e7-4f39-b900-0997087233a4
@github-actions

github-actions Bot commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

📦 Build metrics

Artifact sizes for dfff429 vs the base branch (323b7d7).

Packages (compressed .nupkg)

Artifact base PR Δ
Microsoft.UI.Reactor.nupkg 1.70 MB 1.70 MB -4 B (0.00%) ≈
Microsoft.UI.Reactor.Advanced.nupkg 487.6 KB 487.6 KB +2 B (0.00%) ≈
Microsoft.UI.Reactor.Devtools.nupkg 283.4 KB 283.4 KB +2 B (0.00%) ≈

Assemblies in Microsoft.UI.Reactor

Artifact base PR Δ
Reactor.Analyzers.dll 373.0 KB 373.0 KB +0 B (0.00%) ≈
Reactor.dll 2.43 MB 2.43 MB +0 B (0.00%) ≈
Reactor.Localization.Generator.dll 16.0 KB 16.0 KB +0 B (0.00%) ≈
Reactor.Wrappers.Abstractions.dll 10.5 KB 10.5 KB +0 B (0.00%) ≈
Reactor.Wrappers.Generator.dll 99.5 KB 99.5 KB +0 B (0.00%) ≈

Assemblies in Microsoft.UI.Reactor.Advanced

Artifact base PR Δ
Reactor.Advanced.dll 1021.5 KB 1021.5 KB +0 B (0.00%) ≈

Assemblies in Microsoft.UI.Reactor.Devtools

Artifact base PR Δ
Microsoft.UI.Reactor.Devtools.dll 784.0 KB 784.0 KB +0 B (0.00%) ≈

No size change beyond the noise floor. ✅

✅ smaller / ⚠️ larger / ≈ within noise. Sizes come from a Release dotnet pack on the CI runner: packages are the compressed .nupkg download size, assemblies the uncompressed DLL inside it.
workflow run.

@github-actions

github-actions Bot commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

🧪 Merged coverage

Coverage for dfff429 vs the base branch (323b7d7) — unit + selftest merged.

Metric base PR Δ
Line 85.78% 85.77% -0.01 pp ≈
Branch 77.55% (957/1234) 77.63% (958/1234) +0.08 pp ≈

No coverage change beyond the noise floor. ✅

✅ higher / ⚠️ lower / ≈ within noise. Δ is in percentage points; coverage is unit + selftest merged (Debug x64) on the CI runner. Cobertura reports attached to the workflow run as artifacts.

The Release build promotes analyzer warnings to errors, so blocking on the
redirected output failed the `Build solution`, `Pack` and `Merged coverage`
jobs with four diagnostics the Debug build never surfaced: xUnit1031 for
reading `Task.Result` inside a test method, and xUnit1051 for calling
`ReadToEndAsync` without a cancellation token.

The pattern was lifted from an existing helper in this suite, where it is legal
because the analyzer only inspects test methods. Await the reads instead and
thread `TestContext.Current.CancellationToken` through, matching how the rest of
the repo cancels async test work. Both reads still start before the wait, so a
child filling one pipe buffer cannot deadlock against it.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 93d332ff-21e7-4f39-b900-0997087233a4
@azchohfi
Alexandre Zollinger Chohfi (azchohfi) merged commit 9ace8a2 into main Sep 11, 2026
30 checks passed
@azchohfi
Alexandre Zollinger Chohfi (azchohfi) deleted the azchohfi-docs-version-selector branch September 11, 2026 07:20
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.

1 participant