Skip to content

FEAT: Multi-version docs on GitHub Pages (replaces RTD)#1866

Merged
romanlutz merged 22 commits into
microsoft:mainfrom
romanlutz:romanlutz/musical-memory
Jun 2, 2026
Merged

FEAT: Multi-version docs on GitHub Pages (replaces RTD)#1866
romanlutz merged 22 commits into
microsoft:mainfrom
romanlutz:romanlutz/musical-memory

Conversation

@romanlutz

@romanlutz romanlutz commented May 31, 2026

Copy link
Copy Markdown
Contributor

Description

PyRIT's docs migrate from Read the Docs to a multi-version GitHub Pages site at microsoft.github.io/PyRIT/, with a custom version picker that actually works.

Screenshot

The picker (rendered on /code/scenarios/scenario-parameters/ in 0.13.0), bottom-right of every page:

Closed Open
Picker closed Picker open with sibling fallback

The dropdown shows all configured versions with the current one highlighted. The suffix on latest and 0.12.1 (→ /code/scenarios/common-scenario-parameters and → /code/scenarios/configuring-scenarios) is the sibling-aware fallback: those versions don't have a scenario-parameters page, so the picker offers the closest real sibling page in each version's code/scenarios/ section instead of dumping the user at the version home.

Why

Read the Docs got enabled briefly but its addons script is fundamentally incompatible with myst-theme's React hydration -- the addons <script> and <meta> tags get wiped during hydration (verified: 18 React hydration errors per page), so the version flyout never renders. GitHub Pages was already deployed at microsoft.github.io/PyRIT/ but built only main. This PR replaces RTD with a versioned GH Pages deploy and a vanilla-JS picker that survives the same upstream hydration bug RTD couldn't.

How

  • .github/docs-versions.yml -- single source of truth. Initial entries: latest (main), 0.13.0 (stable + default), 0.12.1. Adding 0.14.0 next week is a one-file, one-line PR.
  • .github/workflows/docs.yml -- rewritten as a matrix per version (each version checks out its own ref, runs uv sync --frozen against its own uv.lock for bit-for-bit reproducible rebuilds, builds its own myst site with version-specific BASE_URL), then a merge job composes dist/, generates per-version pages.json manifests, writes root + /stable/ redirects, injects the picker, and deploys.
  • build_scripts/inject_version_picker.py -- post-build HTML patcher that inlines the picker JS (with CSS bundled as a string constant) into every page's <head>. Inlining matters: myst-theme strips external <script>, <link>, and <style> tags during React hydration; inlined scripts execute during HTML parse before hydration runs, and self-restore themselves and their styles via MutationObservers.
  • build_scripts/version_picker_assets/picker.js -- vanilla-JS bottom-right floating dropdown. Hooks history.pushState/popstate so it re-mounts on SPA navigation. Uses each version's pages.json manifest to find the best fallback page when the target URL doesn't exist there (longest common path prefix wins, tiebreak alphabetical). Shows → /code/scenarios/configuring-scenarios style suffixes so the user knows where they'll land before clicking.
  • build_scripts/generate_pages_manifest.py -- writes a per-version pages.json listing every rendered HTML page. Runs in the deploy merge job (not per-version build) so older release branches don't need this script in their tree.
  • build_scripts/preview_server.py -- stdlib-only local preview server that emulates GH Pages URL resolution (/api/index/api/index.html) so local preview matches production.
  • Custom 404.html -- inline JS fetches the version's manifest and auto-redirects to the closest available page, so dead URLs degrade gracefully instead of stranding the user.
  • doc/contributing/10_release_process.md -- adds a new step "Update the documentation site versions" between current step 5 and step 6 (renumbered subsequent steps). Future releases edit one file in .github/docs-versions.yml and the picker auto-updates across every version's pages.
  • .readthedocs.yaml -- deleted. RTD has only existed for a day, no users / backlinks to preserve.

URL layout (after deploy)

microsoft.github.io/PyRIT/                  -> redirects to /0.13.0/
microsoft.github.io/PyRIT/0.13.0/           -> releases/v0.13.0 (stable)
microsoft.github.io/PyRIT/0.12.1/           -> releases/v0.12.1
microsoft.github.io/PyRIT/latest/           -> main (dev)
microsoft.github.io/PyRIT/stable/           -> redirects to /0.13.0/
microsoft.github.io/PyRIT/versions.json     -> machine-readable manifest

Things that will need user follow-up after merge

  1. Verify the first push-to-main deploy succeeds at microsoft.github.io/PyRIT/.
  2. Disable the RTD project in the RTD dashboard.
  3. Add an actions/cache@v4 step (optional optimization) if rebuilding all versions on every push becomes expensive.

Tests and Documentation

  • 12 unit tests added under tests/unit/build_scripts/:
    • test_inject_version_picker.py (7): idempotency, asset shape, head/body handling, subdir walking, base-URL handling, error paths
    • test_generate_pages_manifest.py (5): root + nested collection, sort order, empty-dir exclusion, error path, empty site
      All passing locally.
  • End-to-end manual testing across the full multi-version stack on a local mirror that emulates GH Pages URL resolution:
    • Picker mounts on every page (landing, deep routes, post-SPA-navigation)
    • Switching versions preserves the URL path when the target page exists in the destination
    • When it doesn't exist, the picker shows → /closest/available/page suffix and links accordingly
    • Direct hits on dead URLs serve the custom 404 page, which auto-redirects via the manifest
    • Stable / root redirects work, versions.json is valid
  • CI proof on fork: all 3 versions (latest + 0.13.0 + 0.12.1) build successfully in parallel (~1m each) via the new matrix.

releases/v0.13.0 and releases/v0.12.1 need to remain reachable for the build matrix; I've already pushed the minimal RTD-config backports there separately so each version's own build still works (those .readthedocs.yaml files are dormant once RTD is disabled in the dashboard).

romanlutz and others added 15 commits May 27, 2026 21:24
The Read the Docs build was completing with exit-code 0 on every command
but was being marked as failed with the post-build notification:

  Index file is not present in HTML output directory
  Your documentation did not generate an index.html at its root directory.

Root cause: in jupyter-book v2 (myst-cli), --all means `build all
frontmatter exports'' (PDF, per doc/myst.yml), not `all output formats''.
It does not produce the static HTML site. The static site requires the
explicit --html flag, which writes to _build/html/ (not _build/site/).

The Makefile's docs-build-all target already uses --all --html --pdf
--strict for exactly this reason; .readthedocs.yaml was just never
brought into alignment.

Verified locally: produces _build/html/index.html (194 KB) and 1897
files. The cp source is updated to match the new output directory.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Migrate PyRIT's docs from RTD to a multi-version GitHub Pages site
with a custom version picker. Replaces the existing single-version
GH Pages workflow.

Background:
* RTD's addons script is incompatible with myst-theme's React hydration
  (verified: 18 React microsoft#418 hydration errors per page; the injected
  <script> and <meta> tags get deleted before the flyout can mount).
* GH Pages was already deployed at microsoft.github.io/PyRIT/ but only
  built main, so it had no version switching at all.
* RTD has only existed for a day, so there are no backlinks to preserve.

Design:
* .github/docs-versions.yml is the single source of truth for which
  versions appear on the site. Initial entries: latest (main),
  0.13.0 (stable, default), 0.12.1.
* .github/workflows/docs.yml runs a matrix per version, then a merge
  job composes dist/, writes versions.json + root/stable redirects,
  injects the picker, and deploys.
* build_scripts/inject_version_picker.py is an idempotent post-build
  script that walks every .html and prepends the picker assets.
* build_scripts/version_picker_assets/picker.{js,css} render a vanilla
  JS floating bottom-right dropdown. No React. Path-preserving.

URL layout:
  /PyRIT/             -> redirects to /PyRIT/0.13.0/
  /PyRIT/0.13.0/      -> releases/v0.13.0 build (stable)
  /PyRIT/0.12.1/      -> releases/v0.12.1 build
  /PyRIT/latest/      -> main build (dev)
  /PyRIT/stable/      -> redirects to /PyRIT/0.13.0/
  /PyRIT/versions.json -> machine-readable manifest

Release process change:
* doc/contributing/10_release_process.md gets a new step 6: 'Update
  the documentation site versions'. Subsequent steps renumbered.

Testing: 7 unit tests for the injector; browser-verified end to end
locally with stub artifacts.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Ubuntu runners don't have pyyaml installed by default; the previous
commit assumed the system Python had it. Add a small pip install step
before the matrix computation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Lets us trigger a deploy manually from the GH UI/CLI without needing a
new commit. Useful when republishing after RTD redirects or testing
the deploy job in isolation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The previous commits in this branch migrate PyRIT's docs to a
multi-version GitHub Pages site, which replaces RTD as the canonical
docs URL. RTD has only existed for a day and has no users, so we can
just delete its config rather than keep it around as a redirect layer.

The .readthedocs.yaml files on releases/v0.13.0 and releases/v0.12.1
are left in place. Those release branches are frozen, RTD will be
disabled in the dashboard post-merge, and the dormant config doesn't
affect the new GH Pages build pipeline.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
# Conflicts:
#	.github/workflows/docs.yml
#	.readthedocs.yaml
The previous 'uv pip install . --group dev' resolved deps fresh from
each ref's pyproject.toml, ignoring uv.lock. That meant a rebuild of
v0.12.1 today could pick up newer patch versions than the maintainer
tested against, defeating the 'frozen release' guarantee.

Switch to 'uv sync --frozen' which honors each branch's uv.lock. Same
group/extras fallback (newer branches use PEP 735, older use legacy
extras). uv venv is no longer needed since 'uv sync' creates one.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The previous design loaded picker.js as an external <script defer src=...>
in <body>. That worked on the landing page but failed on every other route:
the myst-cli/Remix theme aggressively reconciles the body during route
hydration and removes any <script>, <meta>, or <link> tags it didn't render
server-side. Our <script> tag was deleted before the browser could execute
it, so the picker never mounted on /code/framework/ etc.

This is the same upstream bug that breaks the RTD addons flyout (verified
in this branch by browser inspection: 18 React microsoft#418 hydration errors per
page; the RTD addons script and meta tags get wiped the same way).

Fix: inline the entire picker.js source into a <script> tag in <head>.
That makes the JS execute during HTML parse, BEFORE React hydration runs.
By the time React touches our inline script tag (and removes it), the
picker's history hooks and MutationObservers are already live, so it
survives any subsequent body wipes and re-mounts itself.

Picker also gained:
* MutationObserver to wait for hydration quiescence before initial mount
* History.pushState/replaceState/popstate hooks for SPA route changes
* Self-resurrection observer: if our picker DOM node gets removed by a
  later reconciliation, re-mount it
* Manifest fetch cached across re-mounts so reroutes are cheap

Verified locally on a real PyRIT build (BASE_URL=/PyRIT/0.13.0): picker
mounts on / and on /code/framework/, survives SPA navigation between
them, and shows path-preserving links for all 3 versions in the dropdown.

Injector now copies only picker.css to _pyrit/ (no picker.js since it's
inlined). Test suite updated for the new shape.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
After the previous commit moved picker.js inline, browser inspection
showed the picker DOM was mounting on every page but rendering as
an unstyled inline element at the bottom of the body instead of as
a floating bottom-right card. The reason: the <link rel='stylesheet'
href='..picker.css'> tag we wrote to <head> was being removed by
React's hydration along with everything else myst-theme didn't
render server-side. The CSS file got fetched (HTTP 200) but
removing a <link> also removes its applied styles, so the picker
had no positioning rules.

Fix: bundle picker.css into picker.js as a string constant. The
picker JS creates a <style id='pyrit-version-picker-style'> in
<head> at mount time. If React strips it later, a MutationObserver
on <head> re-injects it. Same defensive pattern as the picker DOM
itself.

Also:
* Drop picker.css (now bundled into picker.js)
* Drop _copy_assets() from the injector (no separate asset file)
* No more _pyrit/ asset directory in the built site
* Test suite updated to assert the inline-only shape

Verified locally on a real built PyRIT 0.13.0 page: picker mounts
bottom-right with full styling on / and on /code/framework/, and
the dropdown highlights the current version.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
If a user is on /code/scenarios/scenario-parameters/ in 0.13.0 and clicks
'0.12.1' in the picker, that page may not exist in the older version. The
previous behavior was to either 404 or dump the user back to the version's
home page. Now we walk up the path -- /code/scenarios/, then /code/, then
/ -- and link to the deepest one that exists. Much better UX: the user
lands on a related page in the same section instead of being yanked to
square one.

Picker.js: for each non-current version, fire parallel HEAD requests for
every ancestor of the current page's relative path, then update the link
href to the deepest match. Suffix shows the redirect target ('-> /code/
scenarios') so the user knows what they're getting. The full-path link
stays clean (no suffix) when the page exists in that version.

404.html: same pattern, but for the version the user actually requested
via a typed/external URL. If GH Pages serves 404.html, the inline script
walks ancestors of the requested path within the same version and auto-
window.location.replace()s to the deepest match. If no ancestor exists,
the static fallback page renders with a 'go to default version home'
button and the picker.

Note: the inline script in 404.html runs before the picker injector adds
the <meta name='pyrit-docs-base'>, so the 404 script reads that meta if
present (after injection) and otherwise falls back to splitting the URL
on the first path segment. Both approaches yield the same result for our
deploy.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The previous ancestor-walk fell back to /code/scenarios/ when 0.12.1
didn't have /code/scenarios/scenario-parameters/. But /code/scenarios/
itself has no rendered page in 0.12.1 -- just two subdirs (scenarios/
and configuring-scenarios/) each with their own index.html. GH Pages
correctly 404s the parent dir; my Python http.server lied and returned
a 200 directory listing, which the picker took as 'page exists'.

Fix: probe <path>index.html instead of <path>. That returns 200 only
when an actual rendered page lives at that path. Works identically in
local dev (python http.server) and production (GH Pages).

Same change applied to the 404.html auto-redirect script.

Now on /PyRIT/0.13.0/code/scenarios/scenario-parameters/, both latest
and 0.12.1 correctly fall through ancestors that are empty dirs and
land on the version home, rather than appearing to redirect to a
directory listing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Previously the picker walked up ancestors of the current path looking
for any real rendered page. Most myst-cli intermediate dirs are just
nav containers (no index.html), so the walk would fall all the way to
the version home -- even when a useful sibling page existed in the
same section. From /code/scenarios/scenario-parameters/, switching to
0.12.1 would dump the user at the home page, ignoring that 0.12.1 has
code/scenarios/scenarios/ right there.

Fix: at build time, every version emits a pages.json listing every
rendered page (every index.html). The picker (and 404.html) fetch
that manifest and use it to find the closest available page:

  1) exact path match wins
  2) walk up ancestors; at each level prefer the ancestor itself if
     it's a real page, otherwise pick the best sibling under that
     ancestor (longest common prefix with the target, tiebreak
     alphabetical)
  3) fall through to the version root if nothing else works

Now from /code/scenarios/scenario-parameters/ on 0.13.0:
  * latest -> /code/scenarios/common-scenario-parameters/
  * 0.12.1 -> /code/scenarios/configuring-scenarios/
both real sibling pages in their respective versions' code/scenarios/
section, rather than the version home.

Workflow change: manifest generation runs in the deploy merge job
(not the per-version build job) so older release branches don't need
the new generate_pages_manifest.py script in their tree. Each version's
manifest is written to /<slug>/pages.json after its artifact is staged
into dist/.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Python's stdlib http.server doesn't resolve extensionless URLs like
/api/index -> /api/index.html, but GH Pages does. Previewing a myst-cli
build locally with 'python -m http.server' produced spurious 404s for
the theme's own nav links (which use extensionless URLs that Remix
intercepts client-side and the underlying server is supposed to satisfy
via .html suffix resolution).

build_scripts/preview_server.py is a tiny stdlib-only server that
emulates GH Pages resolution:
  1. file at path -> serve it
  2. dir at path with index.html -> serve the index
  3. path + '.html' is a file -> serve it (the GH Pages 'pretty URL' trick)
  4. fall through to 404, and if a 404.html exists, serve it with status 404

This is for local preview only. CI deploys to actual GH Pages, which
already does all of this.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comment thread build_scripts/version_picker_assets/picker.js Outdated
Comment thread .github/workflows/docs.yml Outdated
Comment thread .github/workflows/docs.yml Outdated
Comment thread .github/workflows/docs.yml Outdated
Comment thread .github/workflows/docs.yml Outdated
Comment thread .github/workflows/docs.yml
@rlundeen2 rlundeen2 self-assigned this Jun 1, 2026
romanlutz and others added 5 commits June 1, 2026 12:33
Reviewer (PR microsoft#1866): copilot flagged this as dead code, which it is --
the manifest-based findClosestPage helper replaced ancestorPaths in an
earlier commit and the old function was never deleted.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Reviewer (PR microsoft#1866) flagged several inline-in-YAML blocks as risky:

  * The 'Compute matrix' step was a python heredoc embedded in YAML. A
    formatting slip could silently produce a corrupt versions.json that
    only surfaces at runtime.
  * Step 'Compose dist/' was 175 lines of bash + heredoc HTML templates
    that can't be linted or tested.
  * 'echo \ > dist/versions.json' would break if anyone
    reformatted the JSON output to be multi-line.

Move all of it into two real Python scripts with full unit-test coverage:

  build_scripts/resolve_docs_matrix.py
    Reads .github/docs-versions.yml, validates schema (every slug
    declared, default/stable point at real versions, no missing fields),
    and writes the four step outputs (matrix, default, stable,
    versions_json) to \. Rejects multi-line values so a
    corrupt step output can never reach downstream jobs.

  build_scripts/compose_docs_dist.py
    Stages each artifacts/site-<slug>/ into dist/<slug>/, generates the
    per-version pages.json manifest, writes the root and /stable/
    redirect HTMLs and the 404.html (with the auto-redirect script
    previously inline in the workflow), and writes the top-level
    versions.json directly via json.dump (no shell-quoted round trip).

Both scripts emit compact JSON, validate args, return non-zero on bad
input, and have tests under tests/unit/build_scripts/.

The 404.html template lives in compose_docs_dist.py as a string
constant with format() placeholders for docs_base and default_slug.
Rendering is unit-tested
(test_render_not_found_includes_auto_redirect_script).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Workflow drops from ~390 lines to ~225 with identical behavior:

  * 'Compute matrix' step is now one line invoking
    build_scripts/resolve_docs_matrix.py with --github-output.
  * 'Compose dist/' step is one line invoking
    build_scripts/compose_docs_dist.py with --artifacts-dir, --dist-dir,
    --config, and --base. All the per-version staging, manifest
    generation, root + stable redirects, and 404.html generation moves
    into the script (where it is unit-tested).
  * The 'Install PyRIT with dev dependencies' step no longer hides
    stderr behind 2>/dev/null. Instead, a tiny inline python -c snippet
    reads pyproject.toml via stdlib tomllib up front and prints which
    flag to use (--group dev for PEP 735, --extra dev for legacy
    [project.optional-dependencies]). 'uv sync' then runs that exact
    command with stderr visible -- any real failure surfaces normally.

The deploy job's sparse-checkout adds build_scripts/compose_docs_dist.py
to the script list. The versions job adds build_scripts/resolve_docs_matrix.py.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Releases are frozen branches: same SHA every workflow run -> rebuilding
their docs every push wastes ~2 runner-minutes per shard. Now we key
actions/cache@v4 on (slug, ref SHA, CACHE_VERSION) and gate the entire
install + build pipeline on cache miss. On a typical PR that doesn't
touch a release branch, only latest does a real build; 0.13.0 and
0.12.1 hit the cache and just re-upload their artifacts.

CACHE_VERSION is a manual cache-bust knob -- bump it when changes to
this workflow's toolchain pins or build flags would affect the rendered
HTML output but aren't captured by the source SHA.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comment thread build_scripts/compose_docs_dist.py Outdated
Comment thread tests/unit/build_scripts/test_inject_version_picker.py Outdated
romanlutz and others added 2 commits June 1, 2026 16:54
…losestPage

The findClosestPage + commonSegmentPrefix functions were hand-copied
between picker.js (cross-version sibling matching in the picker dropdown)
and compose_docs_dist.py's NOT_FOUND_HTML_TEMPLATE (404 auto-redirect to
the closest sibling within the current version). The two copies had
already diverged: picker.js's findClosestPage normalizes off query
strings and fragments via .split('?')[0].split('#')[0]; the 404 copy
did not. Either one drifting silently produces wrong fallback
behavior that only manifests in production.

Extract to build_scripts/version_picker_assets/closest_page.js. Both
consumers now substitute the marker '// @@CLOSEST_PAGE_JS@@' with the
contents of that one file at build time:

  * inject_version_picker.py._load_picker_js() reads both files and
    substitutes before inlining into doc HTML pages.
  * compose_docs_dist.py.render_not_found_html() does the same when
    rendering 404.html.

Both substitution sites RuntimeError if the marker is missing, so a
refactor that accidentally drops the marker fails loudly at build time
instead of silently shipping a 404 page with no fallback logic.

Add tests/unit/build_scripts/test_closest_page.py: 19 behavioral tests
that load closest_page.js directly in node and exercise findClosestPage
and commonSegmentPrefix against fixtures. Catches behavioral regressions
in the actual JS, not a Python port. Skipped if node is not on PATH.

Also remove vacuous test_inject_version_picker assertions that were
always true for the test fixture (no <link> or src= in the fixture
HTML to begin with, so the not-in checks couldn't fail). Replaced with
an assertion that the closest-page marker was actually substituted in.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@romanlutz
romanlutz added this pull request to the merge queue Jun 2, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Jun 2, 2026
@romanlutz
romanlutz added this pull request to the merge queue Jun 2, 2026
Merged via the queue into microsoft:main with commit de08f3c Jun 2, 2026
84 of 85 checks passed
@romanlutz
romanlutz deleted the romanlutz/musical-memory branch June 2, 2026 02:19
@romanlutz romanlutz linked an issue Jun 4, 2026 that may be closed by this pull request
6 tasks
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.

DOC Website for PyRIT

2 participants