diff --git a/.github/workflows/build_docs.yml b/.github/workflows/build_docs.yml index 9318d53cd..f69cfeda2 100644 --- a/.github/workflows/build_docs.yml +++ b/.github/workflows/build_docs.yml @@ -53,8 +53,6 @@ jobs: runs-on: ubuntu-latest permissions: contents: write # push the persistent docs-store branch (the durable versioned-docs store) - outputs: - is_release: ${{ steps.relver.outputs.is_release }} steps: - uses: actions/checkout@v5 with: @@ -135,7 +133,9 @@ jobs: BERTINI_GIT_SHA: ${{ steps.meta.outputs.sha }} BERTINI_BUILD_DATE: ${{ steps.meta.outputs.date }} run: | - sphinx-build -b html -W --keep-going source ../../site/python + # -d keeps the .doctrees pickle cache OUT of the published tree (site/python); it is a + # build intermediate, never part of the site. + sphinx-build -b html -W --keep-going -d ../../build/docs/py-doctrees source ../../site/python - name: Add landing page env: @@ -164,10 +164,12 @@ jobs: "(the site keeps only X.Y.Z versions -- deploy happens from real release tags)." fi - - name: Assemble the versioned docs store + - name: Assemble & publish the versioned docs store # Fold the freshly-built site/ into the persistent docs-store branch as /vX.Y.Z/, refresh - # /stable/ and the root landing (derived from every version present), and push it back. The - # branch is the durable byte store; older versions are never rebuilt. Public releases only. + # the /stable/ redirect + root landing (derived from every version present), and push it back. + # GitHub Pages serves this branch DIRECTLY (Pages source = docs-store), so this push IS the + # deploy -- no actions/deploy-pages, no artifact exchange. The branch is the durable byte + # store; older versions are never rebuilt. Public releases only. if: steps.relver.outputs.is_release == 'true' && inputs.deploy env: STORE_BRANCH: docs-store @@ -180,36 +182,11 @@ jobs: echo "No $STORE_BRANCH yet; starting a fresh store." git init -q store && git -C store checkout -q -b "$STORE_BRANCH" fi + # --cname keeps the bertini2.org custom domain in the branch (branch-source Pages reads it). python tools/assemble_versioned_docs.py --site site --store store \ - --version "${{ steps.relver.outputs.version }}" --stable + --version "${{ steps.relver.outputs.version }}" --stable --cname bertini2.org git -C store config user.name "github-actions[bot]" git -C store config user.email "41898282+github-actions[bot]@users.noreply.github.com" git -C store add -A git -C store commit -q -m "docs: publish v${{ steps.relver.outputs.version }}" || echo "store unchanged" git -C store push -q "$REMOTE" HEAD:"$STORE_BRANCH" - rm -rf store/.git # the Pages artifact must not carry the store's git metadata - - - name: Upload Pages artifact (versioned store) - if: steps.relver.outputs.is_release == 'true' && inputs.deploy - uses: actions/upload-pages-artifact@v3 - with: - path: store - - deploy_docs: - name: Deploy to GitHub Pages - needs: - - build_docs - # Deploy only for public releases: prerelease/dev builds have no versioned artifact to publish, - # and deploying a flat build would clobber the multi-version site. - if: needs.build_docs.result == 'success' && inputs.deploy && needs.build_docs.outputs.is_release == 'true' - runs-on: ubuntu-latest - permissions: - pages: write - id-token: write - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - name: Deploy - id: deployment - uses: actions/deploy-pages@v4 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 431fe3da2..fcd6e1d95 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -173,6 +173,6 @@ jobs: needs: [check_version, build_and_test] uses: ./.github/workflows/build_docs.yml permissions: - contents: write # build_docs pushes the persistent docs-store branch (versioned docs) - pages: write - id-token: write \ No newline at end of file + # Pages serves docs-store directly (branch source), so publishing is just pushing the branch; + # no pages:/id-token: needed -- only write access to push the store. + contents: write # build_docs pushes the persistent docs-store branch (versioned docs) \ No newline at end of file diff --git a/docs/adr/0050-docs-deploy-from-branch-not-deploy-pages.md b/docs/adr/0050-docs-deploy-from-branch-not-deploy-pages.md new file mode 100644 index 000000000..b9e55176e --- /dev/null +++ b/docs/adr/0050-docs-deploy-from-branch-not-deploy-pages.md @@ -0,0 +1,84 @@ +# ADR-0050: Publish the versioned docs by serving the `docs-store` branch, not `actions/deploy-pages` + +**Status:** Accepted +**Date:** 2026-07-08 + +## Context + +bertini2.org keeps historical docs: each real release lives forever under `/vX.Y.Z/`, a +moving `/stable/` points at the newest release, and the root is a landing page listing +versions (see the docs-versioning scheme). `build_docs.yml` builds a single-version `site/` +(Doxygen → `site/cpp`, Sphinx **furo** → `site/python`, custom landing → `site/index.html`) +and `tools/assemble_versioned_docs.py` folds it into a persistent **`docs-store` branch**: +the durable byte store, one `/vX.Y.Z/` per release, `versions.json` + root `index.html` +derived from whatever version dirs are present (truth-vs-derived, mirroring the records +doctrine). + +The original plan (and the first implementation) then published that store to GitHub Pages +with `actions/upload-pages-artifact` + `actions/deploy-pages` — Pages `build_type: workflow` +— deliberately to avoid a Pages *settings* change. **This did not work for the multi-version +store**, and the failure was expensive to diagnose: + +- The first bug was a gate: `deploy_docs` keyed on a cross-job output + (`needs.build_docs.outputs.is_release`) that **did not propagate** into the downstream job + `if` in the reusable-workflow context. The job *skipped* even though `build_docs` had set + the output and uploaded the artifact. +- Fixing the gate exposed the real wall: `actions/deploy-pages` then failed **structurally** + with an Azure **`BlobNotFound`** ("the specified blob does not exist"), *reproducibly*, + across re-runs. Yet: + - the `github-pages` artifact was valid and **downloaded fine** via the normal artifacts + API (a well-formed ~140 MB / 9.6k-file site: `.nojekyll`, root `index.html`, no + symlinks); + - permissions were not the cause (a flat single-version deploy in **May 2026 succeeded + with fewer permissions**); + - it was not transient (identical instant failure on every retry). + +The one thing that differed from the working May deploy was the **payload** — a flat +single-version site (worked) vs. the ~140 MB versioned store (failed). Whatever the precise +trigger inside GitHub's Actions→Pages *artifact-exchange*, it is opaque to us, only +reproducible through ~20-minute CI cycles, and not something we control or can pin. + +## Decision + +**Stop deploying Pages from an Actions artifact. Serve GitHub Pages directly from the +`docs-store` branch** (Pages `build_type: legacy`, source = `docs-store` `/`). + +Because `build_docs` already **pushes** the assembled store to `docs-store`, that push *is* +the deploy — GitHub rebuilds the branch-source Pages site automatically. Consequences: + +- **`actions/deploy-pages` and `actions/upload-pages-artifact` are removed**, and the entire + `deploy_docs` job is deleted. There is no artifact exchange, so `BlobNotFound` cannot + occur; there is no cross-job output feeding a downstream `if`, so the propagation bug is + moot. `publish.yml`'s docs caller drops the now-unused `pages:` / `id-token:` permissions + and keeps only `contents: write` (to push the branch). +- **The custom domain moves into the branch.** Switching the Pages source *cleared* the + `bertini2.org` custom domain, because branch-source Pages reads the domain from a `CNAME` + file. We add `/CNAME` to `docs-store` and re-set the Pages `cname`; the assembler writes + `/CNAME` via `--cname bertini2.org` so every rebuild preserves it. **This ADR knowingly + overrides the earlier "no Pages settings change" constraint** — that constraint assumed + the Actions deploy worked, and it did not. +- **The published store is slimmed** (independently useful, and it keeps the branch small + since it grows per release): `/stable/` becomes a small **redirect stub** to the newest + `/vX.Y.Z/` instead of a 69 MB byte-for-byte copy, and Sphinx `.doctrees` caches are + dropped (`sphinx-build -d` puts them outside `site/`, and the assembler ignores them on + copy as defense-in-depth). Store: 173 MB → 86 MB, 9589 → 4597 files. + +## Consequences + +- **Docs updates are decoupled from releases.** A prose/source fix = dispatch `build_docs` + (it assembles into `docs-store` and pushes → Pages republishes); a built-HTML typo or a + rollback = push `docs-store` directly. No PyPI, no wheel matrix, no `deploy-pages`. +- **Do not switch docs publishing back to `actions/deploy-pages`.** It failed structurally + with `BlobNotFound` on the versioned store and is a GitHub-side black box; branch-source + is the working path. If you must revisit it, keep branch-source live until a full + multi-version deploy is *proven* green. +- **`/stable/` deep links bounce.** A link to `/stable/some/page` redirects to the version + root, not the exact page — the right trade for a "current docs" entry point; the landing + card links straight to `/vX.Y.Z/`. +- **Tooling/source coupling caveat.** `build_docs` checks out `inputs.ref` for *both* the + doc source and the tooling, so re-publishing an *already-released* version's store with + *newer* tooling can't go through the workflow (the old tag carries the old assembler — + e.g. no `--cname`). Re-slim such a store by hand for one-offs; the pipeline runs clean for + future releases, whose tags carry matching tooling. +- **The custom domain now depends on the `/CNAME` file** in `docs-store`. Deleting it (or + regenerating the branch without `--cname`) drops `bertini2.org` on the next Pages build. diff --git a/docs/adr/README.md b/docs/adr/README.md index 0e549ca46..c50fe2370 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -66,3 +66,4 @@ Each ADR follows the template: | [0046](0046-solver-records-seam-ensure-answered.md) | The solver records seam: solve() is ensure-answered — track records ARE serialized FullPathResults, recall replays them through StoreFullPathResult, manager is sole writer, resume = memoization | Core / records | | [0047](0047-casual-records-surface.md) | The casual records surface: bertini.solve/save/load, Solution = points that remember, CLI records-on-by-default beside b1 files (no flag) | Python + CLI / records | | [0048](0048-cauchy-endgame-security-and-operating-zone.md) | Cauchy divergence handling: security check watches the ENDPOINT, truncates only in the operating zone, no pole-growth truncation (the acceptance gate alone cures junk-success); restores cyclic-6's 156 solutions (refines #70) | Core / endgames | +| [0050](0050-docs-deploy-from-branch-not-deploy-pages.md) | Docs publish by serving the docs-store branch directly (Pages branch-source), NOT actions/deploy-pages — which failed structurally with BlobNotFound on the versioned store; custom domain via a /CNAME file; /stable/ is a redirect, .doctrees dropped | CI / docs | diff --git a/tools/assemble_versioned_docs.py b/tools/assemble_versioned_docs.py index 0ae32db31..a03470f8b 100644 --- a/tools/assemble_versioned_docs.py +++ b/tools/assemble_versioned_docs.py @@ -2,11 +2,15 @@ """Assemble a multi-version documentation tree for bertini2.org. The docs site keeps *historical* versions: each real release lives forever under its own -``/vX.Y.Z/`` directory, a moving ``/stable/`` mirrors the newest release, and the site root is a -landing page that lists the versions. This script performs the accumulation step: it takes a +``/vX.Y.Z/`` directory, a moving ``/stable/`` redirects to the newest release, and the site root is +a landing page that lists the versions. This script performs the accumulation step: it takes a freshly-built single-version ``site/`` and folds it into a persistent *store* directory (in CI, a checkout of the ``docs-store`` branch), without ever rebuilding older versions. +GitHub Pages serves the store *directly from the ``docs-store`` branch* (Pages source = that +branch), so pushing the store IS the deploy -- there is no ``actions/deploy-pages`` step. The +``/CNAME`` file (written when ``--cname`` is given) is what keeps the custom domain across builds. + Truth vs. derived (mirrors the records doctrine, ADR-0045/0047): the ``v*/`` directories present in the store ARE the truth. ``versions.json`` and the root ``index.html`` are *derived, rebuildable views* -- regenerated from whatever version directories exist, every run. Delete a ``v*/`` dir and @@ -18,7 +22,8 @@ /versions.json derived machine-readable version index /style.css shared stylesheet (copied from the built site) /.nojekyll so GitHub Pages serves _static/ etc. verbatim - /stable/ copy of the newest release (deep links work; it is a real copy) + /CNAME custom domain (only when --cname is given); persists it across builds + /stable/ redirect stub to the newest release (a tiny page, NOT a byte-for-byte copy) /vX.Y.Z/ one durable directory per real release /vX.Y.Z/index.html per-version landing (the built site's own index) /vX.Y.Z/{python,cpp,cli}/ @@ -49,6 +54,21 @@ # Prereleases are intentionally NOT matched: they never get a durable directory. _VDIR_RE = re.compile(r"^v(\d+)\.(\d+)\.(\d+)(?:\.post(\d+))?$") +STABLE_REDIRECT_TEMPLATE = """ + + + + + + Bertini 2 -- stable documentation + + +

The stable documentation is the latest release ({vdir}). + Redirecting…

+ + +""" + ROOT_INDEX_TEMPLATE = """ @@ -116,10 +136,22 @@ def parse_version(name: str): def copy_tree(src: Path, dst: Path) -> None: - """Replace ``dst`` with a fresh copy of ``src`` (idempotent for re-releases).""" + """Replace ``dst`` with a fresh copy of ``src`` (idempotent for re-releases). + + Sphinx build intermediates (``.doctrees``) are never published -- they are pure caches, so we + drop them here as defense-in-depth even if the docs build forgot to redirect them out. + """ + if dst.exists(): + shutil.rmtree(dst) + shutil.copytree(src, dst, ignore=shutil.ignore_patterns(".doctrees")) + + +def write_stable_redirect(dst: Path, vdir: str) -> None: + """Point ``/stable/`` at the newest release with a redirect stub (no byte-for-byte copy).""" if dst.exists(): shutil.rmtree(dst) - shutil.copytree(src, dst) + dst.mkdir(parents=True) + (dst / "index.html").write_text(STABLE_REDIRECT_TEMPLATE.format(vdir=vdir)) def discover_versions(store: Path): @@ -182,6 +214,9 @@ def main(argv=None): help="release date recorded for this version (default: today)") ap.add_argument("--style", type=Path, help="stylesheet for the root landing (default: /style.css if present)") + ap.add_argument("--cname", metavar="DOMAIN", + help="write /CNAME with this custom domain (e.g. bertini2.org), so branch-source " + "GitHub Pages keeps the domain across builds") args = ap.parse_args(argv) site: Path = args.site @@ -197,11 +232,13 @@ def main(argv=None): store.mkdir(parents=True, exist_ok=True) (store / ".nojekyll").touch() + if args.cname: + (store / "CNAME").write_text(args.cname.strip() + "\n") vdir = f"v{args.version}" copy_tree(site, store / vdir) if args.stable: - copy_tree(site, store / "stable") + write_stable_redirect(store / "stable", vdir) # Shared stylesheet at the root, so the generated landing can reference /style.css. style_src = args.style or (site / "style.css") @@ -231,7 +268,8 @@ def main(argv=None): ) (store / "index.html").write_text(render_root_index(records, stable)) - print(f"OK: {vdir} written{' + stable' if args.stable else ''}; " + print(f"OK: {vdir} written{' + stable redirect' if args.stable else ''}" + f"{' + CNAME ' + args.cname if args.cname else ''}; " f"{len(records)} version(s) in store, stable=v{stable}.") return 0 diff --git a/tools/test_assemble_versioned_docs.py b/tools/test_assemble_versioned_docs.py index b1657829d..02b043a11 100644 --- a/tools/test_assemble_versioned_docs.py +++ b/tools/test_assemble_versioned_docs.py @@ -19,18 +19,26 @@ def make_site(tmp_path: Path, marker: str) -> Path: - """A minimal built-site fixture with an underscore dir (to exercise .nojekyll).""" + """A minimal built-site fixture with an underscore dir (to exercise .nojekyll) and a + ``.doctrees`` cache (to exercise the publish-junk drop).""" site = tmp_path / f"site_{marker}" (site / "python" / "_static").mkdir(parents=True, exist_ok=True) + (site / "python" / ".doctrees").mkdir(parents=True, exist_ok=True) (site / "cpp").mkdir(exist_ok=True) (site / "python" / "index.html").write_text(f"

{marker}

") (site / "python" / "_static" / "t.css").write_text("x") + (site / "python" / ".doctrees" / "environment.pickle").write_text("cache-junk") (site / "cpp" / "index.html").write_text(f"

{marker}

") (site / "index.html").write_text("landing") (site / "style.css").write_text(":root{}") return site +def stable_target(store: Path) -> str: + """Return the redirect target embedded in the /stable/ stub (e.g. '/v3.1.0/').""" + return (store / "stable" / "index.html").read_text() + + def run(site, store, version, *, stable=False, date="2026-01-01"): argv = ["--site", str(site), "--store", str(store), "--version", version, "--date", date] if stable: @@ -47,7 +55,11 @@ def test_first_release_creates_everything(tmp_path): rc = run(make_site(tmp_path, "3.0.0"), store, "3.0.0", stable=True, date="2026-07-14") assert rc == 0 assert (store / "v3.0.0" / "python" / "index.html").read_text() == "

3.0.0

" - assert (store / "stable" / "cpp" / "index.html").exists() + # /stable/ is a redirect stub to the newest release, NOT a copy of it. + assert "/v3.0.0/" in stable_target(store) + assert not (store / "stable" / "cpp").exists() + # .doctrees caches are dropped, never published. + assert not (store / "v3.0.0" / "python" / ".doctrees").exists() assert (store / ".nojekyll").exists() assert (store / "style.css").exists() data = load_json(store) @@ -69,8 +81,8 @@ def test_newer_release_moves_stable_and_preserves_old(tmp_path): assert [v["version"] for v in data["versions"]] == ["3.1.0", "3.0.0"] # newest first dates = {v["version"]: v["released"] for v in data["versions"]} assert dates == {"3.0.0": "2026-07-14", "3.1.0": "2026-09-01"} - # /stable/ now mirrors 3.1.0 - assert (store / "stable" / "python" / "index.html").read_text() == "

3.1.0

" + # /stable/ now redirects to 3.1.0 + assert "/v3.1.0/" in stable_target(store) def test_patch_to_old_line_does_not_move_stable(tmp_path): @@ -82,7 +94,7 @@ def test_patch_to_old_line_does_not_move_stable(tmp_path): data = load_json(store) assert data["stable"] == "3.1.0" # unchanged assert [v["version"] for v in data["versions"]] == ["3.1.0", "3.0.1", "3.0.0"] - assert (store / "stable" / "python" / "index.html").read_text() == "

3.1.0

" + assert "/v3.1.0/" in stable_target(store) # redirect unchanged (3.0.1 run had no --stable) def test_re_release_replaces_only_that_dir(tmp_path): @@ -121,6 +133,20 @@ def test_empty_site_rejected(tmp_path): assert run(empty, store, "3.0.0") == 2 +def test_cname_written_when_requested(tmp_path): + store = tmp_path / "store" + argv = ["--site", str(make_site(tmp_path, "3.0.0")), "--store", str(store), + "--version", "3.0.0", "--stable", "--cname", "bertini2.org"] + assert avd.main(argv) == 0 + assert (store / "CNAME").read_text() == "bertini2.org\n" + + +def test_no_cname_by_default(tmp_path): + store = tmp_path / "store" + run(make_site(tmp_path, "3.0.0"), store, "3.0.0", stable=True) + assert not (store / "CNAME").exists() + + def test_parse_version_ordering(): keys = [avd.parse_version(n) for n in ("v3.0.0", "v3.1.0", "v3.0.1", "v10.0.0", "v3.0.0.post1")] assert None not in keys