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 = """ + +
+ + + +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: