Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 10 additions & 33 deletions .github/workflows/build_docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
6 changes: 3 additions & 3 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
# 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)
84 changes: 84 additions & 0 deletions docs/adr/0050-docs-deploy-from-branch-not-deploy-pages.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
52 changes: 45 additions & 7 deletions tools/assemble_versioned_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}/
Expand Down Expand Up @@ -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 = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0; url=/{vdir}/">
<link rel="canonical" href="/{vdir}/">
<title>Bertini 2 -- stable documentation</title>
</head>
<body>
<p>The stable documentation is <a href="/{vdir}/">the latest release ({vdir})</a>.
Redirecting&hellip;</p>
</body>
</html>
"""

ROOT_INDEX_TEMPLATE = """<!DOCTYPE html>
<html lang="en">
<head>
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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: <site>/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
Expand All @@ -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")
Expand Down Expand Up @@ -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

Expand Down
36 changes: 31 additions & 5 deletions tools/test_assemble_versioned_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"<h1>{marker}</h1>")
(site / "python" / "_static" / "t.css").write_text("x")
(site / "python" / ".doctrees" / "environment.pickle").write_text("cache-junk")
(site / "cpp" / "index.html").write_text(f"<h1>{marker}</h1>")
(site / "index.html").write_text("<html>landing</html>")
(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:
Expand All @@ -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() == "<h1>3.0.0</h1>"
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)
Expand All @@ -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() == "<h1>3.1.0</h1>"
# /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):
Expand All @@ -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() == "<h1>3.1.0</h1>"
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):
Expand Down Expand Up @@ -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
Expand Down
Loading