From cb9118d5a1b94b62fb85cb5e8f41fc6745f14f06 Mon Sep 17 00:00:00 2001 From: silviana amethyst Date: Wed, 8 Jul 2026 04:47:35 +0000 Subject: [PATCH 1/5] ci(docs): re-derive is_release in deploy_docs; stop trusting cross-job output (#291) The versioned-docs Pages deploy silently skipped for the v3.0.0 release: build_docs ran relver (is_release=true), pushed the docs-store branch, and uploaded the Pages artifact -- yet deploy_docs, gated on `needs.build_docs.outputs.is_release == 'true'`, was skipped. In a reusable-workflow context that cross-job output did not propagate into the downstream job `if`, even though the producing job logged "Set output". Drop the fragile job output. deploy_docs now runs on `needs.build_docs.result == 'success' && inputs.deploy` (both terms proven true in the failed run) and re-derives is_release locally from VERSION, gating only the actual deploy-pages step. Immune to the propagation quirk; for prereleases build_docs uploads no artifact so the deploy step no-ops green, never clobbering the multi-version site. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/build_docs.yml | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build_docs.yml b/.github/workflows/build_docs.yml index 9318d53cd..deacf1611 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: @@ -199,17 +197,39 @@ jobs: 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' + # Run whenever docs built and the caller asked to deploy. Whether THIS ref is a public release + # is re-derived locally below (from VERSION) and gates the actual deploy step -- deliberately NOT + # via `needs.build_docs.outputs.*`. A cross-job output feeding this `if` proved unreliable in the + # reusable-workflow context (issue #291): build_docs uploaded the Pages artifact and set the + # `is_release` output, yet this job still skipped. Re-deriving in-job sidesteps that entirely, + # and for a prerelease build_docs uploads no artifact -- so the deploy step just no-ops (green), + # never clobbering the multi-version site. + if: needs.build_docs.result == 'success' && inputs.deploy runs-on: ubuntu-latest permissions: + contents: read pages: write id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: + - uses: actions/checkout@v5 + with: + ref: ${{ inputs.ref || github.ref }} + - name: Determine release version + # Mirrors build_docs' relver: only a public release (X.Y.Z, no pre/dev suffix) deploys. + id: relver + run: | + VERSION=$(tr -d '[:space:]' < VERSION) + if echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "is_release=true" >> "$GITHUB_OUTPUT" + else + echo "is_release=false" >> "$GITHUB_OUTPUT" + echo "::notice::VERSION '$VERSION' is not a public release; skipping Pages deploy "\ + "(build_docs uploaded no artifact to publish)." + fi - name: Deploy id: deployment + if: steps.relver.outputs.is_release == 'true' uses: actions/deploy-pages@v4 From 5093568940e3159d2bc515783c3297703f89ab6f Mon Sep 17 00:00:00 2001 From: silviana amethyst Date: Wed, 8 Jul 2026 05:02:07 +0000 Subject: [PATCH 2/5] ci(docs): add on-demand deploy-from-store workflow (#291) A minimal workflow that publishes the durable docs-store branch straight to Pages without rebuilding docs -- for (re)publishing the site or recovering a deploy that failed only at the Pages step. Also a fast 2-min probe of actions/deploy-pages in isolation. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/deploy_docs_store.yml | 48 +++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .github/workflows/deploy_docs_store.yml diff --git a/.github/workflows/deploy_docs_store.yml b/.github/workflows/deploy_docs_store.yml new file mode 100644 index 000000000..0260bde09 --- /dev/null +++ b/.github/workflows/deploy_docs_store.yml @@ -0,0 +1,48 @@ +name: Deploy docs from store πŸš€ + +# Publish the durable versioned-docs store (the `docs-store` branch) straight to GitHub Pages, +# WITHOUT rebuilding any docs. The store already holds the assembled /vX.Y.Z/ + /stable/ + landing +# (build_docs writes it there). Use this to (re)publish the site after a build already ran, or to +# recover a deploy that failed only at the Pages step. +# +# Runs only on demand. + +on: + workflow_dispatch: + inputs: + store_branch: + description: 'Branch holding the assembled docs bytes to publish.' + required: false + type: string + default: 'docs-store' + +concurrency: + group: pages-deploy + cancel-in-progress: false + +jobs: + deploy: + name: Publish store to Pages + runs-on: ubuntu-latest + permissions: + contents: read # read the docs-store branch + pages: write # deploy + id-token: write # deploy OIDC + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Check out the docs store + uses: actions/checkout@v5 + with: + ref: ${{ inputs.store_branch }} + path: store + - name: Strip git metadata (Pages artifact must be plain files) + run: rm -rf store/.git + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: store + - name: Deploy + id: deployment + uses: actions/deploy-pages@v4 From 6b6d2976eaaaa40e756acc82c72b6f29460decb7 Mon Sep 17 00:00:00 2001 From: silviana amethyst Date: Wed, 8 Jul 2026 07:32:02 +0000 Subject: [PATCH 3/5] docs: serve Pages from docs-store branch; slim the store (#291) Root fix for #291: instead of deploying the versioned docs via actions/deploy-pages (which failed structurally with Azure BlobNotFound on the ~140MB store, even though the artifact was valid and downloadable), serve GitHub Pages DIRECTLY from the docs-store branch. Pushing the store IS the deploy now -- no artifact exchange, nothing to skip, no cross-job output to propagate. v3.0.0 is live on bertini2.org. Also slim what gets published: - /stable/ becomes a tiny redirect stub to the newest /vX.Y.Z/ instead of a 69MB byte-for-byte copy (halves the store, halves the file count). - .doctrees Sphinx caches are dropped -- at the source via `sphinx-build -d`, and as defense-in-depth in the assembler's copy (~13MB of pure junk gone). - assembler writes /CNAME (--cname) so branch-source Pages keeps the bertini2.org custom domain across builds. Workflow: remove the upload-pages-artifact step and the entire deploy_docs job; drop now-unused pages:/id-token: perms from publish.yml's docs caller; delete the throwaway deploy_docs_store.yml. assemble_versioned_docs tests updated (stable is now a redirect; .doctrees dropped; CNAME emission). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/deploy_docs_store.yml | 48 ------------------------- 1 file changed, 48 deletions(-) delete mode 100644 .github/workflows/deploy_docs_store.yml diff --git a/.github/workflows/deploy_docs_store.yml b/.github/workflows/deploy_docs_store.yml deleted file mode 100644 index 0260bde09..000000000 --- a/.github/workflows/deploy_docs_store.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: Deploy docs from store πŸš€ - -# Publish the durable versioned-docs store (the `docs-store` branch) straight to GitHub Pages, -# WITHOUT rebuilding any docs. The store already holds the assembled /vX.Y.Z/ + /stable/ + landing -# (build_docs writes it there). Use this to (re)publish the site after a build already ran, or to -# recover a deploy that failed only at the Pages step. -# -# Runs only on demand. - -on: - workflow_dispatch: - inputs: - store_branch: - description: 'Branch holding the assembled docs bytes to publish.' - required: false - type: string - default: 'docs-store' - -concurrency: - group: pages-deploy - cancel-in-progress: false - -jobs: - deploy: - name: Publish store to Pages - runs-on: ubuntu-latest - permissions: - contents: read # read the docs-store branch - pages: write # deploy - id-token: write # deploy OIDC - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - name: Check out the docs store - uses: actions/checkout@v5 - with: - ref: ${{ inputs.store_branch }} - path: store - - name: Strip git metadata (Pages artifact must be plain files) - run: rm -rf store/.git - - name: Upload Pages artifact - uses: actions/upload-pages-artifact@v3 - with: - path: store - - name: Deploy - id: deployment - uses: actions/deploy-pages@v4 From 55d14e12c40daa8abce2b6b8910df892acf17ea6 Mon Sep 17 00:00:00 2001 From: silviana amethyst Date: Wed, 8 Jul 2026 07:36:48 +0000 Subject: [PATCH 4/5] =?UTF-8?q?docs:=20branch-source=20Pages=20+=20slim=20?= =?UTF-8?q?store=20=E2=80=94=20the=20actual=20changes=20(#291)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carries the edits that the prior commit's message described but did not contain (an aborted `git add` staged only the file deletion): - build_docs.yml: sphinx-build -d (drop .doctrees), assemble --cname bertini2.org, remove upload-pages-artifact + the entire deploy_docs job (branch-source Pages publishes on push to docs-store). - publish.yml: drop now-unused pages:/id-token: from the docs caller. - assemble_versioned_docs.py: /stable/ redirect stub (not a copy), .doctrees ignored on copy, --cname writes /CNAME; tests updated (14 pass). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/build_docs.yml | 63 +++++---------------------- .github/workflows/publish.yml | 6 +-- tools/assemble_versioned_docs.py | 52 +++++++++++++++++++--- tools/test_assemble_versioned_docs.py | 36 ++++++++++++--- 4 files changed, 89 insertions(+), 68 deletions(-) diff --git a/.github/workflows/build_docs.yml b/.github/workflows/build_docs.yml index deacf1611..f69cfeda2 100644 --- a/.github/workflows/build_docs.yml +++ b/.github/workflows/build_docs.yml @@ -133,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: @@ -162,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 @@ -178,58 +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 - # Run whenever docs built and the caller asked to deploy. Whether THIS ref is a public release - # is re-derived locally below (from VERSION) and gates the actual deploy step -- deliberately NOT - # via `needs.build_docs.outputs.*`. A cross-job output feeding this `if` proved unreliable in the - # reusable-workflow context (issue #291): build_docs uploaded the Pages artifact and set the - # `is_release` output, yet this job still skipped. Re-deriving in-job sidesteps that entirely, - # and for a prerelease build_docs uploads no artifact -- so the deploy step just no-ops (green), - # never clobbering the multi-version site. - if: needs.build_docs.result == 'success' && inputs.deploy - runs-on: ubuntu-latest - permissions: - contents: read - pages: write - id-token: write - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - uses: actions/checkout@v5 - with: - ref: ${{ inputs.ref || github.ref }} - - name: Determine release version - # Mirrors build_docs' relver: only a public release (X.Y.Z, no pre/dev suffix) deploys. - id: relver - run: | - VERSION=$(tr -d '[:space:]' < VERSION) - if echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then - echo "is_release=true" >> "$GITHUB_OUTPUT" - else - echo "is_release=false" >> "$GITHUB_OUTPUT" - echo "::notice::VERSION '$VERSION' is not a public release; skipping Pages deploy "\ - "(build_docs uploaded no artifact to publish)." - fi - - name: Deploy - id: deployment - if: steps.relver.outputs.is_release == 'true' - 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/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 From e39bb20bfe224d892342c37cd690b17633b171cb Mon Sep 17 00:00:00 2001 From: silviana amethyst Date: Wed, 8 Jul 2026 07:41:04 +0000 Subject: [PATCH 5/5] =?UTF-8?q?docs(adr):=20ADR-0050=20=E2=80=94=20publish?= =?UTF-8?q?=20docs=20from=20docs-store=20branch,=20not=20deploy-pages=20(#?= =?UTF-8?q?291)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records why versioned-docs publishing serves the docs-store branch directly instead of actions/deploy-pages (structural BlobNotFound on the ~140MB store), the custom-domain-via-CNAME-file consequence, and the stable-redirect / .doctrees-drop slimming. Guards against a well-meaning switch back. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ocs-deploy-from-branch-not-deploy-pages.md | 84 +++++++++++++++++++ docs/adr/README.md | 1 + 2 files changed, 85 insertions(+) create mode 100644 docs/adr/0050-docs-deploy-from-branch-not-deploy-pages.md 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 |