diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index 7b52beecc..a2d68678f 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -82,6 +82,11 @@ jobs: ;; esac + - name: Validate committed release notes + if: steps.validate_tag.outputs.should_publish == 'true' + run: | + poetry run python -c "from corneto.release import _ensure_release_notes; _ensure_release_notes('${{ steps.validate_tag.outputs.version }}')" + # 7. Build source & wheel (only if should publish) - name: Build package if: steps.validate_tag.outputs.should_publish == 'true' diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index f9b78f005..c05ecaec4 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -13,6 +13,13 @@ on: workflows: ["Publish Python Package"] types: [completed] +permissions: + contents: write + +concurrency: + group: docs-pages + cancel-in-progress: false + jobs: build-and-deploy: runs-on: ubuntu-latest @@ -83,9 +90,7 @@ jobs: - name: Sync GitHub Releases to Docs env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - sleep 30 - python scripts/sync_releases.py --repo saezlab/corneto --docs-dir docs + run: python scripts/sync_releases.py --repo saezlab/corneto --docs-dir docs - name: Build Docs env: @@ -93,57 +98,46 @@ jobs: DOCS_BASE_URL: https://corneto.org run: poetry run sphinx-build -W --keep-going -b html docs docs/_build/html - - name: Deploy “stable” docs - if: ${{ steps.set_version.outputs.version_folder == 'stable' }} - uses: peaceiris/actions-gh-pages@v4 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./docs/_build/html - destination_dir: stable - keep_files: true - - - name: Deploy versioned docs for release tag - if: ${{ steps.set_version.outputs.version_folder != 'stable' }} - uses: peaceiris/actions-gh-pages@v4 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./docs/_build/html - destination_dir: ${{ steps.set_version.outputs.version_folder }} - keep_files: true - - - name: Prepare root redirect + - name: Check out gh-pages run: | - mkdir -p temp_root - touch temp_root/.nojekyll - cp docs/custom-index.html temp_root/index.html - - name: Generate switcher.json + set -euo pipefail + git fetch origin gh-pages:refs/remotes/origin/gh-pages + git worktree add --detach gh-pages-worktree refs/remotes/origin/gh-pages + + - name: Prepare Pages tree env: DOCS_BASE_URL: https://corneto.org run: | - python scripts/generate_switcher.py --output temp_root/switcher.json - - - name: Deploy root redirect - uses: peaceiris/actions-gh-pages@v4 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: temp_root - destination_dir: ./ - keep_files: true - - - name: Patch switcher URL in published HTML + set -euo pipefail + root_args=() + if [ "${{ steps.set_version.outputs.version_folder }}" = "stable" ]; then + root_args+=(--update-root) + fi + poetry run python scripts/prepare_docs_pages.py \ + --pages-dir gh-pages-worktree \ + --build-dir docs/_build/html \ + --landing-dir docs/landing \ + --version-folder "${{ steps.set_version.outputs.version_folder }}" \ + --base-url "${DOCS_BASE_URL}" \ + "${root_args[@]}" + + - name: Commit and deploy Pages tree + env: + VERSION_FOLDER: ${{ steps.set_version.outputs.version_folder }} run: | - git fetch origin gh-pages - git worktree add gh-pages-worktree gh-pages - python scripts/patch_switcher_urls.py \ - --root gh-pages-worktree \ - --new-url https://corneto.org/switcher.json + set -euo pipefail cd gh-pages-worktree - if [ -n "$(git status --porcelain)" ]; then - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "chore: update switcher json url" - git push origin gh-pages - else - echo "No switcher URL changes detected." + if [ -z "$(git status --porcelain)" ]; then + echo "No Pages changes to deploy." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "docs: deploy ${VERSION_FOLDER}" + git fetch origin gh-pages + if ! git merge --ff-only origin/gh-pages; then + echo "gh-pages advanced during deployment; refusing to overwrite it." >&2 + exit 1 fi + git push origin HEAD:gh-pages diff --git a/RELEASE.md b/RELEASE.md index 941140b18..ad53f294e 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -38,7 +38,40 @@ poetry run release v1.2.3 Pre-release tags use the same flow, for example `v1.2.3-alpha.0`, `v1.2.3-beta.0`, or `v1.2.3-rc.0`. The helper confirms that the tree is clean, the checkout is on and matches the selected remote's `main`, and the tag does -not already exist. +not already exist. It also validates the committed release page before a tag +can be created. + +## Preparing release notes + +Add `docs/releases/.md` and include it in `docs/releases/index.md` before +opening the public release pull request. Use the tag exactly as written; do not +rewrite `v1.0.0-rc.4` as `CORNETO 1.0.0 RC4`. Pre-releases include the same +badge used by the documentation release synchronizer: + +```markdown +# Release v1.0.0-rc.4 {bdg-warning}`Pre-release` + +One or two sentences describing the purpose of the release. + +## Highlights + +- The most important user-facing change. +- Another important change. + +## Additional context + +Optional installation, migration, compatibility, or method-specific details. +``` + +For a stable release, omit the pre-release badge: + +```markdown +# Release v1.0.0 +``` + +The release helper and tag publication workflow both enforce the canonical +title and the `## Highlights` section. This keeps manually prepared pages +consistent with pages produced by `scripts/sync_releases.py`. Useful options: diff --git a/corneto/release.py b/corneto/release.py index caddfddbc..71736591b 100644 --- a/corneto/release.py +++ b/corneto/release.py @@ -6,6 +6,7 @@ import re import subprocess import sys +from pathlib import Path from typing import Sequence VERSION_RE = re.compile(r"^v\d+\.\d+\.\d+(?:-(?:alpha|beta|rc)\.\d+)?$") @@ -72,6 +73,27 @@ def _ensure_tag_does_not_exist(version: str, remote: str) -> None: raise ReleaseError(f"Tag already exists on {remote}: {version}") +def _expected_release_heading(version: str) -> str: + heading = f"# Release {version}" + if re.search(r"-(?:alpha|beta|rc)\.\d+$", version): + heading += " {bdg-warning}`Pre-release`" + return heading + + +def _ensure_release_notes(version: str, *, root: Path = Path(".")) -> None: + notes_path = root / "docs" / "releases" / f"{version}.md" + if not notes_path.is_file(): + raise ReleaseError(f"Missing release notes: {notes_path}") + + content = notes_path.read_text(encoding="utf-8") + first_line = next((line.strip() for line in content.splitlines() if line.strip()), "") + expected_heading = _expected_release_heading(version) + if first_line != expected_heading: + raise ReleaseError(f"Release notes must start with {expected_heading!r}; found {first_line!r} in {notes_path}.") + if not re.search(r"^## Highlights\s*$", content, flags=re.MULTILINE): + raise ReleaseError(f"Release notes must contain a '## Highlights' section: {notes_path}") + + def _create_and_push_tag(version: str, remote: str) -> None: _run(["git", "tag", "-a", version, "-m", version], check=True) _run(["git", "push", remote, version], check=True) @@ -114,6 +136,7 @@ def main(argv: Sequence[str] | None = None) -> int: _ensure_on_main() _ensure_up_to_date_with_remote_main(args.remote) _ensure_tag_does_not_exist(version, args.remote) + _ensure_release_notes(version) if args.dry_run: print(f"[dry-run] All checks passed. Would create and push tag: {version}") diff --git a/docs/custom-index.html b/docs/custom-index.html deleted file mode 100644 index dfb0fc1b0..000000000 --- a/docs/custom-index.html +++ /dev/null @@ -1,431 +0,0 @@ - - - - - - CORNETO: Unified network inference from omics data - - - - - - - - - - - - - - - - - -
- -
- Corneto Logo -

CORNETO

-

Unified knowledge-driven network inference from omics data

-
- -
- - -
- -

- Support us with a ⭐ on GitHub and help grow the community! -

- - ★ Star (…) - -
-
- - - - - - - - - - - - - - - - diff --git a/docs/landing/assets/carnival-schematic.png b/docs/landing/assets/carnival-schematic.png new file mode 100644 index 000000000..f1d304567 Binary files /dev/null and b/docs/landing/assets/carnival-schematic.png differ diff --git a/docs/landing/assets/corneto-abstract.jpg b/docs/landing/assets/corneto-abstract.jpg new file mode 100644 index 000000000..1eb2117bb Binary files /dev/null and b/docs/landing/assets/corneto-abstract.jpg differ diff --git a/docs/landing/assets/corneto-logo-512px.png b/docs/landing/assets/corneto-logo-512px.png new file mode 100644 index 000000000..94506396d Binary files /dev/null and b/docs/landing/assets/corneto-logo-512px.png differ diff --git a/docs/landing/assets/decider-eu-logo.png b/docs/landing/assets/decider-eu-logo.png new file mode 100644 index 000000000..5899d9dfa Binary files /dev/null and b/docs/landing/assets/decider-eu-logo.png differ diff --git a/docs/landing/assets/embl-ebi-logo.png b/docs/landing/assets/embl-ebi-logo.png new file mode 100644 index 000000000..bf6b310fa Binary files /dev/null and b/docs/landing/assets/embl-ebi-logo.png differ diff --git a/docs/landing/assets/favicon.ico b/docs/landing/assets/favicon.ico new file mode 100644 index 000000000..b99d9887e Binary files /dev/null and b/docs/landing/assets/favicon.ico differ diff --git a/docs/landing/assets/fba-schematic.png b/docs/landing/assets/fba-schematic.png new file mode 100644 index 000000000..8fb1fd513 Binary files /dev/null and b/docs/landing/assets/fba-schematic.png differ diff --git a/docs/landing/assets/feasible-space.png b/docs/landing/assets/feasible-space.png new file mode 100644 index 000000000..4b1f7b634 Binary files /dev/null and b/docs/landing/assets/feasible-space.png differ diff --git a/docs/landing/assets/permedcoe-eu-logo.png b/docs/landing/assets/permedcoe-eu-logo.png new file mode 100644 index 000000000..cef9c8336 Binary files /dev/null and b/docs/landing/assets/permedcoe-eu-logo.png differ diff --git a/docs/landing/assets/saezlab-logo.png b/docs/landing/assets/saezlab-logo.png new file mode 100644 index 000000000..d5e8a630b Binary files /dev/null and b/docs/landing/assets/saezlab-logo.png differ diff --git a/docs/landing/assets/ukhd-logo.jpg b/docs/landing/assets/ukhd-logo.jpg new file mode 100644 index 000000000..32154fd2b Binary files /dev/null and b/docs/landing/assets/ukhd-logo.jpg differ diff --git a/docs/landing/index.html b/docs/landing/index.html new file mode 100644 index 000000000..1db704a0d --- /dev/null +++ b/docs/landing/index.html @@ -0,0 +1,1295 @@ + + + + + + + CORNETO | Network optimization for biology + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+

Network optimization, for biology.

+ +

+ An open-source Python framework for expressing and solving biological network optimization problems, + from network inference to general-purpose graph-based modeling. +

+ +
+ GitHub stars + Nat. Mach. Intell.2025 + BSD-3-Clause open source +
+
+
+ +
+
+ + +
+
+

For computational biology

+

Infer mechanistic networks from your omics data.

+

+ Move from gene expression, phosphoproteomics, or + metabolomics to condition-specific signaling and + metabolic networks, using biological knowledge. +

+
    +
  • Intracellular signaling from transcriptomics
  • +
  • Context-specific metabolic networks from omics
  • +
  • Multi-condition network inference
  • +
  • Compatible with Decoupler, Omnipath, COBRApy, and more.
  • +
+
+
+

For optimization & modeling

+

A composable API for optimization on graphs.

+

+ Build network optimization problems with modular formulations + for flows, paths, trees, and acyclic networks, then solve them + with the backend and solver of your choice. +

+
    +
  • Reusable constraint & objective building blocks
  • +
  • Backend-agnostic (CVXPY / PICOS; +20 solvers)
  • +
  • Supports directed, undirected and mixed (hyper)graphs
  • +
  • Graphviz and WebAssembly plotting support
  • +
+
+
+
+
+ +
+ + + +
+ +
+
+ + +
+
+

NetworkCommons

+

CORNETO is part of NetworkCommons.

+

+ NetworkCommons helps you build context-specific biological networks by combining omics data, prior + knowledge, and network inference methods in one platform. It makes it easier to access data, run + different methods, and compare results across workflows. Many of its optimization-based methods are + powered by CORNETO, the underlying engine for flexible network inference. +

+

+ Paton V, Türei D, Ivanova O, et al. + NetworkCommons: bridging data, knowledge, and methods to + build and evaluate context-specific biological networks. + Bioinformatics, 2025. +

+ +
+
+
+ NetworkCommons graphical abstract +
+
+
+
+
+ +
+ + +
+
+

Nature Machine Intelligence  ·  2025

+

Unifying multi-sample network inference from prior knowledge and omics data with CORNETO.

+

+ Rodriguez-Mier, P., Garrido-Rodriguez, M., Gabor, A., & Saez-Rodriguez, J. +

+ + Read on nature.com → + +
+ +
+
+ BibTeX + +
+
@article{rodriguez-mier2025,
+  title   = {Unifying multi-sample network inference from prior knowledge
+             and omics data with CORNETO},
+  author  = {Rodriguez-Mier, Pablo and Garrido-Rodriguez, Martin
+             and Gabor, Attila and Saez-Rodriguez, Julio},
+  journal = {Nature Machine Intelligence},
+  year    = {2025},
+  doi     = {10.1038/s42256-025-01069-9},
+  url     = {https://www.nature.com/articles/s42256-025-01069-9}
+}
+
+
+
+ + + + + + + + + + + diff --git a/docs/releases/index.md b/docs/releases/index.md index b2b800f72..758934fa3 100644 --- a/docs/releases/index.md +++ b/docs/releases/index.md @@ -7,6 +7,7 @@ This section contains detailed release notes for CORNETO versions, documenting n ```{toctree} :maxdepth: 1 +v1.0.0-rc.5 v1.0.0-rc.4 v1.0.0-rc.3 v1.0.0-rc.1 diff --git a/docs/releases/v1.0.0-rc.4.md b/docs/releases/v1.0.0-rc.4.md index e229fd742..0c880a406 100644 --- a/docs/releases/v1.0.0-rc.4.md +++ b/docs/releases/v1.0.0-rc.4.md @@ -1,9 +1,9 @@ -# CORNETO 1.0.0 RC4 +# Release v1.0.0-rc.4 {bdg-warning}`Pre-release` CORNETO 1.0.0 RC4 adds an AnnNet-centered signaling workflow and clarifies how experimental data are represented across CORNETO methods. -## AnnNet and CellNOptDAG +## Highlights - New signaling helpers read signed interactions and multi-condition perturbation data from AnnNet, build a `CellNOptDAG` optimization problem, @@ -15,6 +15,11 @@ experimental data are represented across CORNETO methods. - The tutorial uses normalized synthetic measurements with experimental variation, making the difference between observed values and Boolean model predictions visible. +- The Getting Started guide now presents `Data` as CORNETO's general + representation of samples and measured features, while method-specific + `build` and `build_many` functions are explained as convenience interfaces. + +## AnnNet and CellNOptDAG Install the optional AnnNet integration with: @@ -22,10 +27,5 @@ Install the optional AnnNet integration with: pip install "corneto[annnet]" ``` -## Data documentation - -The Getting Started guide now presents `Data` as CORNETO's general -representation of samples and measured features. Method-specific `build` and -`build_many` functions are explained as convenient ways to create this data -for common analyses, while `build_from_data` remains the general interface for -annotated or reusable datasets. +The general `build_from_data` method interface remains available for annotated +or reusable CORNETO datasets. diff --git a/docs/releases/v1.0.0-rc.5.md b/docs/releases/v1.0.0-rc.5.md new file mode 100644 index 000000000..9cc24dc1b --- /dev/null +++ b/docs/releases/v1.0.0-rc.5.md @@ -0,0 +1,23 @@ +# Release v1.0.0-rc.5 {bdg-warning}`Pre-release` + +CORNETO 1.0.0 RC5 refreshes the project website and strengthens the release and +documentation publication safeguards ahead of the final 1.0 release. + +## Highlights + +- A new self-contained landing page presents CORNETO's biological network + inference and graph optimization capabilities without depending on assets + from a particular documentation version. +- Documentation publishing now prepares and validates the complete Pages tree + before making a single serialized update, preserving historical versions and + preventing release tags from overwriting the homepage. +- Every generated documentation build is checked for the canonical + `corneto.org/switcher.json` URL and the correct version match before it can be + deployed. +- The release helper and package publication workflow now require canonical, + committed release notes before creating or publishing a tag. + +## Compatibility + +This release does not change the CORNETO Python API. The landing-page language +and links target the supported 1.0 documentation and interfaces. diff --git a/scripts/build_all_docs_local.sh b/scripts/build_all_docs_local.sh index 39c246270..d8a2899a0 100755 --- a/scripts/build_all_docs_local.sh +++ b/scripts/build_all_docs_local.sh @@ -133,7 +133,9 @@ for tag in $(git tag -l "v*" --sort=v:refname); do done echo "==> Updating root index and switcher" -cp "${repo_root}/docs/custom-index.html" "${pages_wt}/index.html" +cp "${repo_root}/docs/landing/index.html" "${pages_wt}/index.html" +mkdir -p "${pages_wt}/assets" +rsync -a --delete "${repo_root}/docs/landing/assets/" "${pages_wt}/assets/" touch "${pages_wt}/.nojekyll" "${poetry_bin}" run python "${repo_root}/scripts/generate_switcher.py" \ --output "${pages_wt}/switcher.json" \ diff --git a/scripts/prepare_docs_pages.py b/scripts/prepare_docs_pages.py new file mode 100644 index 000000000..5fac3e389 --- /dev/null +++ b/scripts/prepare_docs_pages.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""Prepare a checked-out gh-pages tree for one documentation deployment.""" + +from __future__ import annotations + +import argparse +import json +import re +import shutil +from html.parser import HTMLParser +from pathlib import Path +from urllib.parse import unquote, urlparse + +try: + from .generate_switcher import build_entries +except ImportError: # pragma: no cover - direct script execution + from generate_switcher import build_entries + + +VERSION_FOLDER_RE = re.compile(r"^(?:stable|v[0-9A-Za-z][0-9A-Za-z._-]*)$") +CSS_URL_RE = re.compile(r"url\(\s*(['\"]?)(.*?)\1\s*\)", re.IGNORECASE) +SWITCHER_URL_RE = re.compile(r"DOCUMENTATION_OPTIONS\.theme_switcher_json_url\s*=\s*(['\"])(?P.*?)\1") +SWITCHER_VERSION_RE = re.compile(r"DOCUMENTATION_OPTIONS\.theme_switcher_version_match\s*=\s*(['\"])(?P.*?)\1") +ASSET_ATTRIBUTES = {"src", "poster"} +LANDING_HOST = "corneto.org" + + +class _LandingReferenceParser(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.asset_references: list[str] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + attributes = dict(attrs) + if tag == "meta" and attributes.get("property") == "og:image" and attributes.get("content"): + self.asset_references.append(attributes["content"]) + if tag == "meta" and attributes.get("name") == "twitter:image" and attributes.get("content"): + self.asset_references.append(attributes["content"]) + for name, value in attrs: + if not value: + continue + if name in ASSET_ATTRIBUTES: + self.asset_references.append(value) + elif name == "srcset": + self.asset_references.extend(item.strip().split()[0] for item in value.split(",") if item.strip()) + elif name == "href" and _is_landing_asset_reference(value): + self.asset_references.append(value) + + +def _asset_relative_path(reference: str) -> Path | None: + parsed = urlparse(reference) + if parsed.scheme in {"data", "mailto", "javascript"}: + return None + if parsed.scheme or parsed.netloc: + if parsed.scheme not in {"http", "https"} or parsed.netloc != LANDING_HOST: + return None + path = unquote(parsed.path).lstrip("/") + else: + path = unquote(parsed.path).lstrip("/") + if not path.startswith("assets/"): + return None + return Path(path) + + +def _is_landing_asset_reference(reference: str) -> bool: + return _asset_relative_path(reference) is not None + + +def validate_landing_bundle(landing_dir: Path) -> list[Path]: + """Validate local asset references and return their bundle-relative paths.""" + landing_dir = landing_dir.resolve() + index_path = landing_dir / "index.html" + assets_dir = landing_dir / "assets" + if not index_path.is_file(): + raise ValueError(f"Landing page is missing: {index_path}") + if not assets_dir.is_dir(): + raise ValueError(f"Landing assets directory is missing: {assets_dir}") + + html = index_path.read_text(encoding="utf-8") + parser = _LandingReferenceParser() + parser.feed(html) + parser.asset_references.extend(match.group(2) for match in CSS_URL_RE.finditer(html)) + + resolved_references: list[Path] = [] + missing: list[str] = [] + invalid: list[str] = [] + for reference in parser.asset_references: + relative_path = _asset_relative_path(reference) + if relative_path is None: + parsed = urlparse(reference) + if not (parsed.scheme or parsed.netloc) and reference not in {"", "/"}: + invalid.append(reference) + continue + candidate = (landing_dir / relative_path).resolve() + try: + candidate.relative_to(assets_dir.resolve()) + except ValueError: + invalid.append(reference) + continue + if not candidate.is_file(): + missing.append(reference) + continue + resolved_references.append(relative_path) + + if invalid: + raise ValueError(f"Landing asset references must use assets/: {sorted(set(invalid))}") + if missing: + raise ValueError(f"Landing assets are missing: {sorted(set(missing))}") + if not resolved_references: + raise ValueError("Landing page does not reference any local assets") + return sorted(set(resolved_references)) + + +def _validate_version_folder(version_folder: str) -> None: + if not VERSION_FOLDER_RE.fullmatch(version_folder): + raise ValueError(f"Unsafe documentation destination: {version_folder!r}") + + +def _validate_nonempty_directory(path: Path, label: str) -> None: + if not path.is_dir() or not any(item.is_file() for item in path.rglob("*")): + raise ValueError(f"{label} is missing or empty: {path}") + + +def validate_built_switcher(build_dir: Path, version_folder: str, base_url: str) -> None: + """Require the generated docs to target the global switcher correctly.""" + index_path = build_dir / "index.html" + if not index_path.is_file(): + raise ValueError(f"Generated documentation index is missing: {index_path}") + html = index_path.read_text(encoding="utf-8") + url_match = SWITCHER_URL_RE.search(html) + version_match = SWITCHER_VERSION_RE.search(html) + if not url_match or not version_match: + raise ValueError("Generated documentation is missing the theme switcher configuration") + + expected_url = f"{base_url.rstrip('/')}/switcher.json" + actual_url = url_match.group("value") + if actual_url != expected_url and not actual_url.startswith(f"{expected_url}?"): + raise ValueError(f"Unexpected switcher URL: expected {expected_url!r}, found {actual_url!r}") + actual_version = version_match.group("value") + if actual_version != version_folder: + raise ValueError(f"Unexpected switcher version match: expected {version_folder!r}, found {actual_version!r}") + + +def _replace_tree(source: Path, destination: Path) -> None: + if destination.is_symlink(): + raise ValueError(f"Refusing to replace symlinked destination: {destination}") + if destination.exists(): + shutil.rmtree(destination) + shutil.copytree(source, destination) + + +def prepare_pages_tree( + *, + pages_dir: Path, + build_dir: Path, + landing_dir: Path, + version_folder: str, + base_url: str, + update_root: bool, +) -> None: + """Update one version and the global Pages files in an existing checkout.""" + _validate_version_folder(version_folder) + if update_root and version_folder != "stable": + raise ValueError("Only the stable deployment may update the root landing page") + pages_dir = pages_dir.resolve() + build_dir = build_dir.resolve() + landing_dir = landing_dir.resolve() + if not pages_dir.is_dir(): + raise ValueError(f"Pages checkout is missing: {pages_dir}") + _validate_nonempty_directory(build_dir, "Sphinx build") + validate_built_switcher(build_dir, version_folder, base_url) + if update_root: + validate_landing_bundle(landing_dir) + + _replace_tree(build_dir, pages_dir / version_folder) + + if update_root: + shutil.copy2(landing_dir / "index.html", pages_dir / "index.html") + _replace_tree(landing_dir / "assets", pages_dir / "assets") + (pages_dir / ".nojekyll").touch() + + switcher_path = pages_dir / "switcher.json" + entries = build_entries(base_url.rstrip("/")) + switcher_path.write_text(json.dumps(entries, indent=2) + "\n", encoding="utf-8") + + +def main() -> int: + """Run the deployment preparation command.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--pages-dir", required=True, type=Path) + parser.add_argument("--build-dir", required=True, type=Path) + parser.add_argument("--landing-dir", required=True, type=Path) + parser.add_argument("--version-folder", required=True) + parser.add_argument("--base-url", required=True) + parser.add_argument("--update-root", action="store_true") + args = parser.parse_args() + prepare_pages_tree( + pages_dir=args.pages_dir, + build_dir=args.build_dir, + landing_dir=args.landing_dir, + version_folder=args.version_folder, + base_url=args.base_url, + update_root=args.update_root, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/publish_docs_tag.sh b/scripts/publish_docs_tag.sh index ed199e60d..39f418a6e 100755 --- a/scripts/publish_docs_tag.sh +++ b/scripts/publish_docs_tag.sh @@ -4,14 +4,13 @@ set -euo pipefail usage() { cat <<'EOF' Usage: - scripts/publish_docs_tag.sh --tag vX.Y.Z[-suffix] [--base-url URL] [--remote origin] [--python /path/to/python] [--poetry /path/to/poetry] [--update-root] [--keep-temp] [--dry-run] + scripts/publish_docs_tag.sh --tag vX.Y.Z[-suffix] [--base-url URL] [--remote origin] [--python /path/to/python] [--poetry /path/to/poetry] [--keep-temp] [--dry-run] Examples: scripts/publish_docs_tag.sh --tag v1.0.0-beta.3 scripts/publish_docs_tag.sh --tag v1.0.0-beta.3 --base-url https://corneto.org scripts/publish_docs_tag.sh --tag v1.0.0-beta.3 --python /opt/homebrew/bin/python3.11 scripts/publish_docs_tag.sh --tag v1.0.0-beta.3 --poetry /opt/homebrew/bin/poetry - scripts/publish_docs_tag.sh --tag v1.0.0-beta.3 --update-root EOF } @@ -20,7 +19,6 @@ base_url="https://corneto.org" remote="origin" python_bin="" poetry_bin="" -update_root=0 keep_temp=0 dry_run=0 @@ -46,10 +44,6 @@ while [[ $# -gt 0 ]]; do poetry_bin="${2:-}" shift 2 ;; - --update-root) - update_root=1 - shift - ;; --keep-temp) keep_temp=1 shift @@ -153,20 +147,13 @@ fi mkdir -p "${pages_wt}/${tag}" rsync -a --delete "${tag_wt}/docs/_build/html/" "${pages_wt}/${tag}/" -if [[ "${update_root}" -eq 1 ]]; then - echo "==> Updating root index and switcher" - mkdir -p "${pages_wt}" - cp "${tag_wt}/docs/custom-index.html" "${pages_wt}/index.html" - touch "${pages_wt}/.nojekyll" - "${poetry_bin}" run python "${repo_root}/scripts/generate_switcher.py" \ - --output "${pages_wt}/switcher.json" \ - --base-url "${base_url}" - "${poetry_bin}" run python "${repo_root}/scripts/patch_switcher_urls.py" \ - --root "${pages_wt}" \ - --new-url "${base_url}/switcher.json" -else - echo "==> Skipping root index/switcher update (use --update-root to enable)" -fi +echo "==> Updating switcher" +"${poetry_bin}" run python "${repo_root}/scripts/generate_switcher.py" \ + --output "${pages_wt}/switcher.json" \ + --base-url "${base_url}" +"${poetry_bin}" run python "${repo_root}/scripts/patch_switcher_urls.py" \ + --root "${pages_wt}" \ + --new-url "${base_url}/switcher.json" echo "==> Committing and pushing gh-pages changes" cd "${pages_wt}" @@ -179,7 +166,7 @@ if [[ -n "$(git status --porcelain)" ]]; then fi git fetch "${remote}" gh-pages >/dev/null 2>&1 || true if ! git merge --ff-only "${remote}/gh-pages" >/dev/null 2>&1; then - echo "Remote gh-pages moved. Refusing to overwrite. Re-run after updating, or use --update-root with a manual merge." >&2 + echo "Remote gh-pages moved. Refusing to overwrite; re-run after updating." >&2 exit 1 fi git push "${remote}" gh-pages diff --git a/tests/test_prepare_docs_pages.py b/tests/test_prepare_docs_pages.py new file mode 100644 index 000000000..2a2c41b1e --- /dev/null +++ b/tests/test_prepare_docs_pages.py @@ -0,0 +1,173 @@ +"""Tests for assembling the multi-version GitHub Pages tree.""" + +from pathlib import Path + +import pytest + +from scripts.prepare_docs_pages import prepare_pages_tree, validate_built_switcher, validate_landing_bundle + + +def _write(path: Path, content: str = "content") -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def _landing_bundle(root: Path) -> Path: + landing = root / "landing" + _write( + landing / "index.html", + """ + + + +Docs +""", + ) + _write(landing / "assets" / "favicon.ico") + _write(landing / "assets" / "social.jpg") + _write(landing / "assets" / "logo.png") + return landing + + +def _sphinx_build(root: Path, version: str) -> Path: + build = root / f"build-{version}" + _write( + build / "index.html", + f""" +""", + ) + return build + + +def test_main_deployment_updates_root_and_exactly_replaces_stable(tmp_path): + pages = tmp_path / "pages" + build = _sphinx_build(tmp_path, "stable") + landing = _landing_bundle(tmp_path) + _write(pages / "stable" / "stale.html") + _write(pages / "v0.9.0" / "keep.html") + _write(pages / "assets" / "stale.png") + _write(pages / "index.html", "old landing") + _write(pages / "CNAME", "corneto.org") + + prepare_pages_tree( + pages_dir=pages, + build_dir=build, + landing_dir=landing, + version_folder="stable", + base_url="https://corneto.org", + update_root=True, + ) + + assert (pages / "stable" / "index.html").read_text(encoding="utf-8") == (build / "index.html").read_text( + encoding="utf-8" + ) + assert not (pages / "stable" / "stale.html").exists() + assert (pages / "v0.9.0" / "keep.html").is_file() + assert (pages / "CNAME").read_text(encoding="utf-8") == "corneto.org" + assert (pages / "index.html").read_text(encoding="utf-8") == (landing / "index.html").read_text(encoding="utf-8") + assert sorted(path.name for path in (pages / "assets").iterdir()) == ["favicon.ico", "logo.png", "social.jpg"] + assert (pages / ".nojekyll").is_file() + assert (pages / "switcher.json").is_file() + + +def test_tag_deployment_preserves_root_landing_and_assets(tmp_path): + pages = tmp_path / "pages" + build = _sphinx_build(tmp_path, "v1.0.0") + _write(pages / "index.html", "current landing") + _write(pages / "assets" / "current.png") + _write(pages / "v1.0.0" / "stale.html") + + prepare_pages_tree( + pages_dir=pages, + build_dir=build, + landing_dir=tmp_path / "unused-landing", + version_folder="v1.0.0", + base_url="https://corneto.org", + update_root=False, + ) + + assert (pages / "index.html").read_text(encoding="utf-8") == "current landing" + assert (pages / "assets" / "current.png").is_file() + assert (pages / "v1.0.0" / "index.html").read_text(encoding="utf-8") == (build / "index.html").read_text( + encoding="utf-8" + ) + assert not (pages / "v1.0.0" / "stale.html").exists() + assert (pages / "switcher.json").is_file() + + +def test_landing_validation_reports_missing_asset(tmp_path): + landing = _landing_bundle(tmp_path) + (landing / "assets" / "logo.png").unlink() + + with pytest.raises(ValueError, match="Landing assets are missing"): + validate_landing_bundle(landing) + + +def test_landing_validation_checks_same_site_social_image(tmp_path): + landing = _landing_bundle(tmp_path) + (landing / "assets" / "social.jpg").unlink() + + with pytest.raises(ValueError, match=r"https://corneto\.org/assets/social\.jpg"): + validate_landing_bundle(landing) + + +def test_built_switcher_rejects_wrong_url(tmp_path): + build = _sphinx_build(tmp_path, "stable") + index = build / "index.html" + index.write_text(index.read_text(encoding="utf-8").replace("corneto.org", "example.org"), encoding="utf-8") + + with pytest.raises(ValueError, match="Unexpected switcher URL"): + validate_built_switcher(build, "stable", "https://corneto.org") + + +def test_built_switcher_rejects_wrong_version(tmp_path): + build = _sphinx_build(tmp_path, "latest") + + with pytest.raises(ValueError, match="Unexpected switcher version match"): + validate_built_switcher(build, "stable", "https://corneto.org") + + +def test_built_switcher_rejects_missing_configuration(tmp_path): + build = tmp_path / "build" + _write(build / "index.html", "") + + with pytest.raises(ValueError, match="missing the theme switcher configuration"): + validate_built_switcher(build, "stable", "https://corneto.org") + + +@pytest.mark.parametrize("version_folder", ["latest", "../stable", "v1/escape", "/stable", ""]) +def test_unsafe_version_folder_is_rejected(tmp_path, version_folder): + pages = tmp_path / "pages" + build = tmp_path / "build" + pages.mkdir() + _write(build / "index.html") + + with pytest.raises(ValueError, match="Unsafe documentation destination"): + prepare_pages_tree( + pages_dir=pages, + build_dir=build, + landing_dir=tmp_path / "unused-landing", + version_folder=version_folder, + base_url="https://corneto.org", + update_root=False, + ) + + +def test_tag_cannot_update_root_landing(tmp_path): + pages = tmp_path / "pages" + build = tmp_path / "build" + pages.mkdir() + _write(build / "index.html") + + with pytest.raises(ValueError, match="Only the stable deployment"): + prepare_pages_tree( + pages_dir=pages, + build_dir=build, + landing_dir=_landing_bundle(tmp_path), + version_folder="v1.0.0", + base_url="https://corneto.org", + update_root=True, + ) diff --git a/tests/test_release.py b/tests/test_release.py index 69b76d2ed..136429240 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -22,6 +22,11 @@ def test_dry_run_uses_selected_remote(monkeypatch, capsys): "_ensure_tag_does_not_exist", lambda version, remote: calls.append((version, remote)), ) + monkeypatch.setattr( + release, + "_ensure_release_notes", + lambda version: calls.append(("notes", version)), + ) result = release.main(["v1.0.0-beta.8", "--remote", "public", "--dry-run"]) @@ -30,6 +35,7 @@ def test_dry_run_uses_selected_remote(monkeypatch, capsys): "public", "public", ("v1.0.0-beta.8", "public"), + ("notes", "v1.0.0-beta.8"), ] assert "Would create and push tag: v1.0.0-beta.8" in capsys.readouterr().out @@ -59,3 +65,51 @@ def test_remote_name_cannot_be_an_option(): assert "Invalid remote name" in str(exc) else: raise AssertionError("Expected an invalid remote name to be rejected") + + +def test_release_notes_require_canonical_prerelease_heading_and_highlights(tmp_path): + """Pre-release notes use the same title and badge as generated documentation.""" + notes_dir = tmp_path / "docs" / "releases" + notes_dir.mkdir(parents=True) + notes = notes_dir / "v1.2.3-rc.4.md" + notes.write_text( + "# Release v1.2.3-rc.4 {bdg-warning}`Pre-release`\n\n## Highlights\n\n- A release highlight.\n", + encoding="utf-8", + ) + + release._ensure_release_notes("v1.2.3-rc.4", root=tmp_path) + + +def test_release_notes_reject_inconsistent_title(tmp_path): + """A manually reformatted release name cannot pass the release guard.""" + notes_dir = tmp_path / "docs" / "releases" + notes_dir.mkdir(parents=True) + (notes_dir / "v1.2.3-rc.4.md").write_text( + "# CORNETO 1.2.3 RC4\n\n## Highlights\n", + encoding="utf-8", + ) + + try: + release._ensure_release_notes("v1.2.3-rc.4", root=tmp_path) + except release.ReleaseError as exc: + assert "must start with" in str(exc) + assert "Pre-release" in str(exc) + else: + raise AssertionError("Expected an inconsistent release-note title to be rejected") + + +def test_release_notes_require_highlights(tmp_path): + """Every manually prepared release page retains the shared highlights section.""" + notes_dir = tmp_path / "docs" / "releases" + notes_dir.mkdir(parents=True) + (notes_dir / "v1.2.3.md").write_text( + "# Release v1.2.3\n\n## Details\n", + encoding="utf-8", + ) + + try: + release._ensure_release_notes("v1.2.3", root=tmp_path) + except release.ReleaseError as exc: + assert "## Highlights" in str(exc) + else: + raise AssertionError("Expected missing highlights to be rejected")