diff --git a/.github/workflows/docs-site.yml b/.github/workflows/docs-site.yml new file mode 100644 index 0000000..b86e09c --- /dev/null +++ b/.github/workflows/docs-site.yml @@ -0,0 +1,144 @@ +name: Central documentation pilot + +on: + pull_request: + paths: ['scripts/docs_site.py', 'tests/test_docs_site.py', '.github/workflows/docs-site.yml', 'docs-theme/**', 'index.html'] + push: + branches: [main] + paths: ['scripts/docs_site.py', '.github/workflows/docs-site.yml', 'docs-theme/**', 'index.html'] + schedule: + - cron: '7,37 * * * *' + workflow_dispatch: + inputs: + source_repository: + description: 'Notification source repository (leave all source inputs empty for a manual build)' + type: string + default: '' + source_sha: + description: 'Source Docs run head SHA' + type: string + default: '' + source_run_id: + description: 'Completed source Docs run ID' + type: string + default: '' + source_run_attempt: + description: 'Completed source Docs run attempt' + type: string + default: '' + +permissions: + contents: read + +# PR validation cannot cancel or queue behind production publication. +concurrency: + group: docs-site-${{ github.event_name == 'pull_request' && github.ref || 'publication' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + build: + if: >- + github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' || + (github.repository == 'hw-native-sys/hw-native-sys.github.io' && vars.DOCS_SITE_ENABLED == 'true') + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install pilot checks + run: python -m pip install -r tests/requirements.txt + - name: Test notification validation and publication guards + run: python -m pytest tests/test_docs_site.py -q + - name: Resolve successful upstream documentation snapshot + id: source + env: + GH_TOKEN: ${{ github.token }} + SOURCE_REPOSITORY: ${{ inputs.source_repository }} + SOURCE_SHA: ${{ inputs.source_sha }} + SOURCE_RUN_ID: ${{ inputs.source_run_id }} + SOURCE_RUN_ATTEMPT: ${{ inputs.source_run_attempt }} + run: python scripts/docs_site.py resolve --manifest .docs-build/manifest.json + - name: Check out selected PyPTO source + uses: actions/checkout@v4 + with: + repository: hw-native-sys/pypto + ref: ${{ steps.source.outputs.source_sha }} + path: .docs-build/pypto + persist-credentials: false + - name: Supply the central theme + run: python scripts/docs_site.py prepare --manifest .docs-build/manifest.json --checkout .docs-build/pypto + - name: Install project documentation toolchain + run: python -m pip install -r .docs-build/pypto/docs/requirements.txt + - name: Check and build the selected source + working-directory: .docs-build/pypto + env: + DOCS_REF: ${{ steps.source.outputs.source_sha }} + run: | + source .claude/skills/testing/load-env.sh + python tests/lint/check_docs_nav.py + python tests/lint/check_docs_en_zh_parity.py + python tests/lint/check_docs_symbol_coverage.py + python tests/lint/check_op_docstring_parity.py + python -m pip check + python -m mkdocs build --strict + - name: Check generated pages and shared resources + run: python tests/docs_theme_compat.py check --site .docs-build/pypto/site --project pypto --page api/tile/index.html + - name: Assemble complete pilot artifact + run: python scripts/docs_site.py assemble --manifest .docs-build/manifest.json --checkout .docs-build/pypto --output .docs-build/public + - name: Upload inspectable pilot artifact + uses: actions/upload-artifact@v4 + with: + name: docs-site-pilot + path: .docs-build/public + include-hidden-files: true + if-no-files-found: error + retention-days: 14 + - name: Upload Pages artifact for enabled publication + if: >- + github.repository == 'hw-native-sys/hw-native-sys.github.io' && + github.ref == 'refs/heads/main' && github.event_name != 'pull_request' && + vars.DOCS_SITE_PUBLISH == 'true' + uses: actions/upload-pages-artifact@v3 + with: + path: .docs-build/public + + deploy: + needs: build + if: >- + github.repository == 'hw-native-sys/hw-native-sys.github.io' && + github.ref == 'refs/heads/main' && github.event_name != 'pull_request' && + vars.DOCS_SITE_PUBLISH == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - uses: actions/download-artifact@v4 + with: + name: docs-site-pilot + path: .docs-build/public + - name: Reject superseded or unchanged publication + id: guard + env: + GH_TOKEN: ${{ github.token }} + run: python scripts/docs_site.py guard --manifest .docs-build/public/build-manifest.json + - uses: actions/configure-pages@v5 + if: steps.guard.outputs.changed == 'true' + - uses: actions/deploy-pages@v4 + id: deploy + if: steps.guard.outputs.changed == 'true' diff --git a/.gitignore b/.gitignore index 6c56ff1..ec5143b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ __pycache__/ .pytest_cache/ +.docs-build/ +.venv/ diff --git a/docs/central-documentation.md b/docs/central-documentation.md new file mode 100644 index 0000000..5c67eed --- /dev/null +++ b/docs/central-documentation.md @@ -0,0 +1,68 @@ +# Central documentation pilot + +The website repository can build a successful PyPTO documentation snapshot and +package it together with the homepage. The other four project sites retain +their existing publishing workflows. Full-site search and their migration are +separate follow-up changes; this pilot retains PyPTO's existing bilingual search. + +## Source and artifact contract + +`.github/workflows/docs-site.yml` selects a completed, successful run of +`hw-native-sys/pypto`'s `.github/workflows/docs.yml` on `main`. The selected commit +must still belong to upstream main history. A newer commit whose documentation +CI has not succeeded is not substituted for it. + +The central workflow uses its own checkout's `docs-theme/`, runs PyPTO's +documentation checks and strict build, and uploads `docs-site-pilot`. The +artifact contains the homepage, its existing assets, `/pypto/`, and +`build-manifest.json`. The manifest records both source revisions and the +successful source run. Source links use the selected PyPTO SHA. No compiler, +CANN installation, or device tests are required. + +## CI notifications + +PyPTO's `Notify central documentation` workflow runs after its Docs workflow +completes successfully on upstream `main`. It dispatches `docs-site.yml` on the +website's `main` with `source_repository`, `source_sha`, `source_run_id`, and +`source_run_attempt`. The receiver verifies these claims through GitHub's API +before selecting the latest eligible snapshot. Manual runs leave all four +inputs empty. + +The notification workflow uses a GitHub App installation token scoped to this +website repository with **Actions: write**. Its App ID and private key are +provided to PyPTO through `DOCS_SITE_APP_ID` (Actions variable) and +`DOCS_SITE_APP_PRIVATE_KEY` (Actions secret). A source repository's own +`GITHUB_TOKEN` does not grant cross-repository write access. + +The notification listener must be merged into PyPTO's default branch, and the +receiving workflow must be present on the website's default branch, before this +event path can run. A successful notification means the build was requested; +the central workflow and deployed manifest establish publication success. + +## Activation and ownership + +All switches are repository Actions variables and default to disabled: + +| Repository | Variable | Effect when enabled | +| --- | --- | --- | +| Website | `DOCS_SITE_ENABLED=true` | Enable automatic push and scheduled pilot builds; PR and manual builds already run | +| PyPTO | `DOCS_SITE_NOTIFY_ENABLED=true` | Send notifications after successful upstream Docs runs | +| Website | `DOCS_SITE_PUBLISH=true` | Permit the central main workflow to deploy its validated artifact | +| PyPTO | `DOCS_SITE_PUBLISHER=central` | Stop the original Pages upload/deploy while preserving document validation | + +First enable artifact-only builds and notifications after configuring the App. +Before enabling publication, verify the root-versus-project Pages routing +handoff with an isolated route, select GitHub Actions as the website's Pages +source, and preserve `www.pypto.ai` in its Pages settings. Coordinate PyPTO's +publishing switch and Pages deactivation with that handoff. Changing its workflow +variable alone does not remove the existing project Pages deployment. + +The production workflow is serialized separately from PR checks. Immediately +before deployment it rejects a stale website workflow, superseded PyPTO source, +changed source CI attempt, or an existing publication containing additional +projects. Unchanged input revisions skip deployment. The scheduled check at +minutes 7 and 37 uses the same successful-CI selection as notifications. + +Both default-branch workflow availability and App configuration are prerequisites +for the live notification test. Unit tests and artifact builds do not establish +that the App has been installed or that Pages routing has been handed over. diff --git a/scripts/docs_site.py b/scripts/docs_site.py new file mode 100644 index 0000000..3828848 --- /dev/null +++ b/scripts/docs_site.py @@ -0,0 +1,347 @@ +"""Resolve, assemble, and guard the first centrally built documentation site.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import shutil +import subprocess +import urllib.error +import urllib.request +from datetime import datetime, timezone +from importlib.metadata import version +from pathlib import Path +from typing import Any + +WEBSITE = "hw-native-sys/hw-native-sys.github.io" +SOURCE = "hw-native-sys/pypto" +WORKFLOW = ".github/workflows/docs.yml" +ORIGIN = "https://www.pypto.ai" +SHA = re.compile(r"[0-9a-f]{40}") +ROOT = Path(__file__).resolve().parents[1] + + +class GitHub: + """Read the public repositories through the runner's GitHub CLI.""" + + def get(self, endpoint: str) -> Any: + result = subprocess.run( + ["gh", "api", "--hostname", "github.com", "--method", "GET", endpoint], + check=True, + capture_output=True, + text=True, + timeout=60, + ) + return json.loads(result.stdout) + + def contains(self, repo: str, base: str, head: str) -> bool: + if not SHA.fullmatch(base) or not SHA.fullmatch(head): + raise ValueError("Comparison requires two full commit SHAs") + if base == head: + return True + comparison = self.get(f"repos/{repo}/compare/{base}...{head}") + return comparison["status"] in ("ahead", "identical") + + +def check_run(run: dict[str, Any], workflow_id: int) -> None: + """Accept only a completed, successful upstream main documentation run.""" + if ( + run.get("repository", {}).get("full_name") != SOURCE + or run.get("head_repository", {}).get("full_name") != SOURCE + or run.get("head_branch") != "main" + or run.get("event") not in ("push", "workflow_dispatch") + or run.get("path") != WORKFLOW + or run.get("workflow_id") != workflow_id + or run.get("status") != "completed" + or run.get("conclusion") != "success" + or not SHA.fullmatch(str(run.get("head_sha", ""))) + or not isinstance(run.get("id"), int) + or not isinstance(run.get("run_attempt"), int) + ): + raise ValueError("Expected a successful upstream PyPTO main Docs run") + + +def resolve( + api: GitHub, + website_sha: str, + notification: dict[str, str], +) -> dict[str, Any]: + """Validate a notification and freeze the newest eligible source snapshot.""" + if not SHA.fullmatch(website_sha): + raise ValueError("Website revision must be a full commit SHA") + workflow = api.get(f"repos/{SOURCE}/actions/workflows/docs.yml") + workflow_id = workflow["id"] + main_sha = api.get(f"repos/{SOURCE}/commits/main")["sha"] + + if any(notification.values()): + if ( + notification.get("source_repository") != SOURCE + or not SHA.fullmatch(notification.get("source_sha", "")) + or not re.fullmatch(r"[1-9][0-9]*", notification.get("source_run_id", "")) + or not re.fullmatch( + r"[1-9][0-9]*", notification.get("source_run_attempt", "") + ) + ): + raise ValueError( + "Notification requires the allowed repository, SHA, run and attempt" + ) + notified = api.get( + f"repos/{SOURCE}/actions/runs/{notification['source_run_id']}" + ) + check_run(notified, workflow_id) + if ( + notified["id"] != int(notification["source_run_id"]) + or notified["head_sha"] != notification["source_sha"] + or notified["run_attempt"] != int(notification["source_run_attempt"]) + or not api.contains(SOURCE, notified["head_sha"], main_sha) + ): + raise ValueError( + "Notification does not match the current upstream run and main history" + ) + + selected = None + for page in range(1, 6): + runs = api.get( + f"repos/{SOURCE}/actions/workflows/{workflow_id}/runs" + f"?branch=main&status=success&per_page=100&page={page}" + )["workflow_runs"] + for candidate in runs: + try: + check_run(candidate, workflow_id) + except ValueError: + continue + if not api.contains(SOURCE, candidate["head_sha"], main_sha): + continue + # Re-read the run: it may have been rerun since the listing was fetched. + current = api.get(f"repos/{SOURCE}/actions/runs/{candidate['id']}") + try: + check_run(current, workflow_id) + except ValueError: + continue + if current["head_sha"] != candidate["head_sha"]: + raise ValueError("Source run changed its commit identity") + # GitHub lists runs newest first. Reruns retain the original run's + # creation order; a pending newer commit is never substituted here. + selected = current + break + if selected is not None or len(runs) < 100: + break + if selected is None: + raise ValueError( + "No successful main Docs snapshot found in the latest 500 successful runs" + ) + + inputs = {"website_sha": website_sha, "pypto_sha": selected["head_sha"]} + return { + "schema_version": 1, + "scope": "pypto-pilot", + "input_digest": hashlib.sha256( + json.dumps(inputs, sort_keys=True).encode() + ).hexdigest(), + "created_at": datetime.now(timezone.utc).isoformat(), + "website": {"repository": WEBSITE, "sha": website_sha}, + "projects": [ + { + "id": "pypto", + "repository": SOURCE, + "sha": selected["head_sha"], + "prefix": "/pypto/", + "workflow_id": workflow_id, + "run_id": selected["id"], + "run_attempt": selected["run_attempt"], + "run_url": selected["html_url"], + } + ], + } + + +def read_manifest(path: Path) -> dict[str, Any]: + """Reject unexpected projects before using a persisted release manifest.""" + manifest = json.loads(path.read_text()) + projects = manifest.get("projects", []) + if ( + manifest.get("schema_version") != 1 + or manifest.get("scope") != "pypto-pilot" + or manifest.get("website", {}).get("repository") != WEBSITE + or not SHA.fullmatch(str(manifest.get("website", {}).get("sha", ""))) + or len(projects) != 1 + or projects[0].get("repository") != SOURCE + or projects[0].get("id") != "pypto" + or projects[0].get("prefix") != "/pypto/" + or not SHA.fullmatch(str(projects[0].get("sha", ""))) + ): + raise ValueError( + "Manifest must describe exactly the PyPTO pilot and website revisions" + ) + inputs = { + "website_sha": manifest["website"]["sha"], + "pypto_sha": projects[0]["sha"], + } + expected = hashlib.sha256(json.dumps(inputs, sort_keys=True).encode()).hexdigest() + if manifest.get("input_digest") != expected: + raise ValueError("Manifest input digest does not match its revisions") + return manifest + + +def verify_checkout(checkout: Path, expected_sha: str) -> None: + actual = subprocess.check_output( + ["git", "-C", str(checkout), "rev-parse", "HEAD"], text=True + ).strip() + if actual != expected_sha: + raise ValueError( + f"Checkout revision {actual} differs from expected {expected_sha}" + ) + + +def prepare(checkout: Path, manifest: dict[str, Any]) -> None: + """Supply the website checkout's shared theme without changing project configuration.""" + verify_checkout(ROOT, manifest["website"]["sha"]) + verify_checkout(checkout, manifest["projects"][0]["sha"]) + target = checkout / ".site-theme" / "docs-theme" + if target.exists(): + raise ValueError(f"Theme checkout already exists: {target}") + shutil.copytree(ROOT / "docs-theme", target) + + +def assemble(checkout: Path, destination: Path, manifest: dict[str, Any]) -> None: + """Package the homepage and the exact validated downstream site together.""" + verify_checkout(ROOT, manifest["website"]["sha"]) + verify_checkout(checkout, manifest["projects"][0]["sha"]) + site = checkout / "site" + if not (site / "index.html").is_file() or not (site / "zh/index.html").is_file(): + raise ValueError("PyPTO English and Chinese build outputs are required") + if any(path.is_symlink() for path in site.rglob("*")): + raise ValueError("Pages artifacts must not contain symbolic links") + if destination.exists(): + raise ValueError(f"Output directory already exists: {destination}") + destination.mkdir(parents=True) + shutil.copy2(ROOT / "index.html", destination / "index.html") + shutil.copytree( + ROOT / "docs-theme/overrides/assets", + destination / "docs-theme/overrides/assets", + ) + shutil.copytree(site, destination / "pypto") + (destination / ".nojekyll").touch() + manifest["renderer"] = { + "mkdocs": version("mkdocs"), + "material": version("mkdocs-material"), + } + (destination / "build-manifest.json").write_text( + json.dumps(manifest, indent=2) + "\n" + ) + + +def published_manifest() -> dict[str, Any] | None: + request = urllib.request.Request( + f"{ORIGIN}/build-manifest.json", + headers={ + "Cache-Control": "no-cache", + "User-Agent": "pypto-docs-publisher/1.0", + }, + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + return json.load(response) + except urllib.error.HTTPError as error: + if error.code == 404: + return None + raise + + +def guard( + api: GitHub, manifest: dict[str, Any], previous: dict[str, Any] | None +) -> bool: + """Reject retired inputs and confirm the source CI still succeeded before publication.""" + project = manifest["projects"][0] + workflow_id = api.get(f"repos/{SOURCE}/actions/workflows/docs.yml")["id"] + run = api.get(f"repos/{SOURCE}/actions/runs/{project['run_id']}") + check_run(run, workflow_id) + if ( + run["head_sha"] != project["sha"] + or run["run_attempt"] != project["run_attempt"] + ): + raise ValueError("Source CI no longer matches the assembled artifact") + website_head = api.get(f"repos/{WEBSITE}/commits/main")["sha"] + if manifest["website"]["sha"] != website_head: + raise ValueError( + "Website main advanced; rebuild with the current publishing workflow" + ) + source_head = api.get(f"repos/{SOURCE}/commits/main")["sha"] + if not api.contains(SOURCE, project["sha"], source_head): + raise ValueError("Selected source is no longer in main history") + latest = resolve(api, manifest["website"]["sha"], {}) + if latest["projects"][0]["sha"] != project["sha"]: + raise ValueError( + "A newer successful Docs snapshot is available; rebuild before publishing" + ) + if previous is None: + return True + if previous.get("scope") != "pypto-pilot" or len(previous.get("projects", [])) != 1: + raise ValueError( + "Existing site is not the PyPTO pilot; refusing to replace its project set" + ) + if previous["projects"][0].get("repository") != SOURCE: + raise ValueError("Existing publication has an unexpected source repository") + if not api.contains( + WEBSITE, previous["website"]["sha"], manifest["website"]["sha"] + ): + raise ValueError( + "Candidate would replace a newer or unrelated website revision" + ) + if not api.contains(SOURCE, previous["projects"][0]["sha"], project["sha"]): + raise ValueError( + "Candidate would replace a newer or unrelated documentation revision" + ) + return previous.get("input_digest") != manifest["input_digest"] + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=("resolve", "prepare", "assemble", "guard")) + parser.add_argument("--manifest", type=Path, required=True) + parser.add_argument("--checkout", type=Path) + parser.add_argument("--output", type=Path) + args = parser.parse_args() + if args.command == "resolve": + website_sha = subprocess.check_output( + ["git", "rev-parse", "HEAD"], cwd=ROOT, text=True + ).strip() + notification = { + key: os.environ.get(key.upper(), "") + for key in ( + "source_repository", + "source_sha", + "source_run_id", + "source_run_attempt", + ) + } + manifest = resolve(GitHub(), website_sha, notification) + args.manifest.parent.mkdir(parents=True, exist_ok=True) + args.manifest.write_text(json.dumps(manifest, indent=2) + "\n") + if os.environ.get("GITHUB_OUTPUT"): + with open(os.environ["GITHUB_OUTPUT"], "a") as output: + output.write(f"source_sha={manifest['projects'][0]['sha']}\n") + print(json.dumps(manifest, indent=2)) + return + manifest = read_manifest(args.manifest) + if args.command == "guard": + changed = guard(GitHub(), manifest, published_manifest()) + if os.environ.get("GITHUB_OUTPUT"): + with open(os.environ["GITHUB_OUTPUT"], "a") as output: + output.write(f"changed={str(changed).lower()}\n") + print(f"Publication inputs changed: {changed}") + elif args.checkout is None: + parser.error("--checkout is required") + elif args.command == "prepare": + prepare(args.checkout, manifest) + elif args.output is None: + parser.error("--output is required for assemble") + else: + assemble(args.checkout, args.output, manifest) + + +if __name__ == "__main__": + main() diff --git a/tests/test_docs_site.py b/tests/test_docs_site.py new file mode 100644 index 0000000..a84f419 --- /dev/null +++ b/tests/test_docs_site.py @@ -0,0 +1,288 @@ +"""Check the trust boundary and version consistency of central publication.""" + +import copy +import io +import json +import urllib.error + +import pytest + +from scripts import docs_site as site + +OLD = "a" * 40 +NEW = "b" * 40 +WEB = "c" * 40 +LATER_WEB = "d" * 40 + + +def docs_run(sha=OLD, run_id=10, **changes): + run = { + "repository": {"full_name": site.SOURCE}, + "head_repository": {"full_name": site.SOURCE}, + "head_branch": "main", + "head_sha": sha, + "event": "push", + "path": site.WORKFLOW, + "workflow_id": 100, + "id": run_id, + "run_attempt": 1, + "status": "completed", + "conclusion": "success", + "html_url": f"https://github.com/{site.SOURCE}/actions/runs/{run_id}", + } + return run | changes + + +class FakeGitHub: + def __init__(self, runs=None): + self.runs = runs if runs is not None else [docs_run()] + self.current = {run["id"]: copy.deepcopy(run) for run in self.runs} + self.main = NEW + self.website_main = WEB + self.history = {(site.SOURCE, OLD, NEW), (site.WEBSITE, WEB, LATER_WEB)} + + def get(self, endpoint): + if endpoint == f"repos/{site.SOURCE}/actions/workflows/docs.yml": + return {"id": 100} + if endpoint == f"repos/{site.SOURCE}/commits/main": + return {"sha": self.main} + if endpoint == f"repos/{site.WEBSITE}/commits/main": + return {"sha": self.website_main} + if endpoint.startswith(f"repos/{site.SOURCE}/actions/workflows/100/runs?"): + return {"workflow_runs": copy.deepcopy(self.runs)} + if endpoint.startswith(f"repos/{site.SOURCE}/actions/runs/"): + return copy.deepcopy(self.current[int(endpoint.rsplit("/", 1)[1])]) + raise AssertionError(f"Unexpected API request: {endpoint}") + + def contains(self, repo, base, head): + return base == head or (repo, base, head) in self.history + + +def notification(sha=OLD, run_id="10", attempt="1"): + return { + "source_repository": site.SOURCE, + "source_sha": sha, + "source_run_id": run_id, + "source_run_attempt": attempt, + } + + +def test_successful_a_is_selected_while_main_b_is_pending(): + api = FakeGitHub( + [docs_run(NEW, 11, status="in_progress", conclusion=None), docs_run()] + ) + manifest = site.resolve(api, WEB, notification()) + assert manifest["projects"][0]["sha"] == OLD + assert manifest["projects"][0]["run_id"] == 10 + + +def test_late_notification_does_not_select_old_commit(): + api = FakeGitHub([docs_run(NEW, 11), docs_run()]) + assert site.resolve(api, WEB, notification())["projects"][0]["sha"] == NEW + + +@pytest.mark.parametrize( + "change", + [ + {"conclusion": "failure"}, + {"conclusion": "cancelled"}, + {"status": "in_progress"}, + {"event": "pull_request"}, + {"event": "pull_request_target"}, + {"head_branch": "feature"}, + {"head_repository": {"full_name": "contributor/pypto"}}, + {"repository": {"full_name": "contributor/pypto"}}, + {"workflow_id": 101}, + {"path": ".github/workflows/ci.yml"}, + ], +) +def test_untrusted_or_unsuccessful_run_cannot_notify(change): + with pytest.raises(ValueError, match="successful upstream"): + site.resolve(FakeGitHub([docs_run(**change)]), WEB, notification()) + + +@pytest.mark.parametrize( + "change", + [ + {"source_repository": "contributor/pypto"}, + {"source_sha": "main"}, + {"source_run_id": "10/../11"}, + {"source_run_attempt": ""}, + {"source_run_id": "0"}, + ], +) +def test_malformed_notification_is_rejected_before_run_lookup(change): + with pytest.raises(ValueError, match="Notification requires"): + site.resolve(FakeGitHub(), WEB, notification() | change) + + +def test_mismatched_attempt_is_rejected(): + with pytest.raises(ValueError, match="does not match"): + site.resolve(FakeGitHub(), WEB, notification(attempt="2")) + + +def test_notification_must_belong_to_current_main_history(): + api = FakeGitHub() + api.history.clear() + with pytest.raises(ValueError, match="main history"): + site.resolve(api, WEB, notification()) + + +def test_rerun_in_progress_after_listing_is_not_selected(): + api = FakeGitHub([docs_run(NEW, 11), docs_run()]) + api.current[11]["status"] = "in_progress" + assert site.resolve(api, WEB, {})["projects"][0]["sha"] == OLD + + +def test_no_eligible_ci_never_falls_back_to_main_head(): + api = FakeGitHub([docs_run(conclusion="failure")]) + with pytest.raises(ValueError, match="No successful"): + site.resolve(api, WEB, {}) + + +def test_retry_of_same_source_has_same_publication_identity(): + first = site.resolve(FakeGitHub(), WEB, {}) + again = site.resolve(FakeGitHub([docs_run(run_attempt=2)]), WEB, {}) + assert first["input_digest"] == again["input_digest"] + + +def test_manifest_rejects_extra_project_and_modified_digest(tmp_path): + manifest = site.resolve(FakeGitHub(), WEB, {}) + path = tmp_path / "manifest.json" + path.write_text(json.dumps(manifest)) + assert site.read_manifest(path)["projects"][0]["sha"] == OLD + manifest["projects"].append(copy.deepcopy(manifest["projects"][0])) + path.write_text(json.dumps(manifest)) + with pytest.raises(ValueError, match="exactly"): + site.read_manifest(path) + manifest["projects"].pop() + manifest["projects"][0]["sha"] = NEW + path.write_text(json.dumps(manifest)) + with pytest.raises(ValueError, match="digest"): + site.read_manifest(path) + + +def test_initial_publication_and_unchanged_inputs(): + api = FakeGitHub() + manifest = site.resolve(api, WEB, {}) + assert site.guard(api, manifest, None) + assert not site.guard(api, manifest, copy.deepcopy(manifest)) + + +def test_old_source_cannot_replace_newer_publication(): + api = FakeGitHub() + old = site.resolve(api, WEB, {}) + newer = site.resolve(FakeGitHub([docs_run(NEW, 11)]), WEB, {}) + with pytest.raises(ValueError, match="newer or unrelated documentation"): + site.guard(api, old, newer) + + +def test_new_website_main_rejects_old_workflow_replay(): + api = FakeGitHub() + manifest = site.resolve(api, WEB, {}) + api.website_main = LATER_WEB + with pytest.raises(ValueError, match="Website main advanced"): + site.guard(api, manifest, None) + + +def test_publication_rechecks_completed_source_attempt(): + api = FakeGitHub() + manifest = site.resolve(api, WEB, {}) + api.current[10]["run_attempt"] = 2 + with pytest.raises(ValueError, match="no longer matches"): + site.guard(api, manifest, None) + + +def test_new_success_during_build_rejects_stale_artifact_even_without_cached_manifest(): + api = FakeGitHub() + manifest = site.resolve(api, WEB, {}) + api.runs.insert(0, docs_run(NEW, 11)) + api.current[11] = docs_run(NEW, 11) + with pytest.raises(ValueError, match="newer successful Docs snapshot"): + site.guard(api, manifest, None) + + +def test_pilot_cannot_overwrite_a_later_multi_project_release(): + api = FakeGitHub() + manifest = site.resolve(api, WEB, {}) + previous = copy.deepcopy(manifest) + previous["projects"].append({"id": "simpler"}) + with pytest.raises(ValueError, match="project set"): + site.guard(api, manifest, previous) + + +@pytest.fixture +def output_sources(tmp_path, monkeypatch): + root = tmp_path / "website" + source = tmp_path / "source" + assets = root / "docs-theme/overrides/assets" + assets.mkdir(parents=True) + (assets / "brand.css").write_text("body {}") + (root / "index.html").write_text( + '' + ) + (root / "internal-source.py").write_text("not part of the public website") + (source / "site/zh").mkdir(parents=True) + (source / "site/index.html").write_text('English') + (source / "site/zh/index.html").write_text('Chinese') + (source / "site/search").mkdir() + (source / "site/search/search_index.json").write_text('{"docs": []}') + monkeypatch.setattr(site, "ROOT", root) + monkeypatch.setattr(site, "verify_checkout", lambda *_: None) + monkeypatch.setattr(site, "version", lambda _: "test-version") + return source, tmp_path / "public" + + +def test_complete_artifact_preserves_paths_and_records_source(output_sources): + source, output = output_sources + manifest = site.resolve(FakeGitHub(), WEB, {}) + site.assemble(source, output, manifest) + assert (output / "index.html").is_file() + assert (output / "docs-theme/overrides/assets/brand.css").is_file() + assert (output / "pypto/zh/index.html").is_file() + assert (output / "pypto/search/search_index.json").is_file() + assert not (output / "internal-source.py").exists() + assert ( + site.read_manifest(output / "build-manifest.json")["projects"][0]["sha"] == OLD + ) + + +def test_missing_chinese_output_prevents_publication(output_sources): + source, output = output_sources + (source / "site/zh/index.html").unlink() + with pytest.raises(ValueError, match="English and Chinese"): + site.assemble(source, output, site.resolve(FakeGitHub(), WEB, {})) + assert not output.exists() + + +def test_symlinks_cannot_escape_into_published_artifact(output_sources): + source, output = output_sources + (source / "site/outside").symlink_to(source.parent) + with pytest.raises(ValueError, match="symbolic"): + site.assemble(source, output, site.resolve(FakeGitHub(), WEB, {})) + assert not output.exists() + + +def test_publication_reader_identifies_itself_and_reads_json(monkeypatch): + def open_manifest(request, timeout): + assert request.full_url == "https://www.pypto.ai/build-manifest.json" + assert request.get_header("User-agent") == "pypto-docs-publisher/1.0" + return io.StringIO('{"scope": "pypto-pilot"}') + + monkeypatch.setattr(site.urllib.request, "urlopen", open_manifest) + assert site.published_manifest() == {"scope": "pypto-pilot"} + + +@pytest.mark.parametrize("code", [403, 404]) +def test_only_a_missing_manifest_allows_first_publication(monkeypatch, code): + def fail(*args, **kwargs): + raise urllib.error.HTTPError( + "https://www.pypto.ai/build-manifest.json", code, "test", {}, None + ) + + monkeypatch.setattr(site.urllib.request, "urlopen", fail) + if code == 404: + assert site.published_manifest() is None + else: + with pytest.raises(urllib.error.HTTPError): + site.published_manifest()