FEAT: Multi-version docs on GitHub Pages (replaces RTD)#1866
Merged
Conversation
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>
rlundeen2
reviewed
Jun 1, 2026
rlundeen2
reviewed
Jun 1, 2026
rlundeen2
reviewed
Jun 1, 2026
rlundeen2
reviewed
Jun 1, 2026
rlundeen2
reviewed
Jun 1, 2026
rlundeen2
reviewed
Jun 1, 2026
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>
rlundeen2
reviewed
Jun 1, 2026
rlundeen2
reviewed
Jun 1, 2026
rlundeen2
approved these changes
Jun 1, 2026
…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>
…o romanlutz/musical-memory
github-merge-queue
Bot
removed this pull request from the merge queue due to failed status checks
Jun 2, 2026
Closed
6 tasks
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.
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/in0.13.0), bottom-right of every page:The dropdown shows all configured versions with the current one highlighted. The suffix on
latestand0.12.1(→ /code/scenarios/common-scenario-parametersand→ /code/scenarios/configuring-scenarios) is the sibling-aware fallback: those versions don't have ascenario-parameterspage, so the picker offers the closest real sibling page in each version'scode/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 atmicrosoft.github.io/PyRIT/but built onlymain. 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, runsuv sync --frozenagainst its ownuv.lockfor bit-for-bit reproducible rebuilds, builds its own myst site with version-specificBASE_URL), then a merge job composesdist/, generates per-versionpages.jsonmanifests, 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 viaMutationObservers.build_scripts/version_picker_assets/picker.js-- vanilla-JS bottom-right floating dropdown. Hookshistory.pushState/popstateso it re-mounts on SPA navigation. Uses each version'spages.jsonmanifest 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-scenariosstyle suffixes so the user knows where they'll land before clicking.build_scripts/generate_pages_manifest.py-- writes a per-versionpages.jsonlisting 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.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.ymland 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)
Things that will need user follow-up after merge
microsoft.github.io/PyRIT/.actions/cache@v4step (optional optimization) if rebuilding all versions on every push becomes expensive.Tests and Documentation
tests/unit/build_scripts/:test_inject_version_picker.py(7): idempotency, asset shape, head/body handling, subdir walking, base-URL handling, error pathstest_generate_pages_manifest.py(5): root + nested collection, sort order, empty-dir exclusion, error path, empty siteAll passing locally.
→ /closest/available/pagesuffix and links accordinglyreleases/v0.13.0andreleases/v0.12.1need 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).