diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 949a97b..664227b 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -127,28 +127,29 @@ jobs: with: name: documentation-html path: docs/_build/html/ + include-hidden-files: true if-no-files-found: error retention-days: 14 publish: needs: documentation - if: github.repository == 'sequince-dev/sequince-examples' && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + # All PR previews are published by the trusted workflow_run workflow. + if: >- + github.repository == 'sequince-dev/sequince-examples' && + github.ref == 'refs/heads/main' && github.event_name != 'pull_request' runs-on: ubuntu-latest timeout-minutes: 55 concurrency: group: examples-rtd-latest cancel-in-progress: false - permissions: - contents: write steps: - uses: actions/checkout@v4 with: - ref: ${{ github.sha }} + ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false - - uses: actions/download-artifact@v4 + - uses: actions/setup-python@v5 with: - name: execution-lock - path: . + python-version: "3.13" - uses: actions/download-artifact@v4 with: name: validated-bundle @@ -165,9 +166,36 @@ jobs: assert Path(str(path) + '.sha256').read_text().split() == [digest(path), path.name] unpack(path, sha, OUTPUT) PY - - name: Publish durable assets and wait for RTD - run: python3 scripts/publish_docs.py + - uses: actions/download-artifact@v4 + with: + name: documentation-html + path: docs/_build/html + - name: Check publication is current + id: publication env: - GH_TOKEN: ${{ github.token }} - RTD_TOKEN: ${{ secrets.RTD_TOKEN }} - RTD_PROJECT: ${{ vars.RTD_PROJECT }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + python3 - <<'PY' + import os + import sys + sys.path.insert(0, 'scripts') + from docs_pipeline import git + number = os.environ['PR_NUMBER'] + ref = f'refs/pull/{number}/head' if number else 'refs/heads/main' + remote = git('ls-remote', 'origin', ref).split() + current = bool(remote) and remote[0] == git('rev-parse', 'HEAD') + with open(os.environ['GITHUB_OUTPUT'], 'a') as output: + output.write(f'current={str(current).lower()}\n') + if not current: + print('Source advanced; skipping stale publication') + PY + - name: Upload HTML to Read the Docs + if: steps.publication.outputs.current == 'true' + # Pinned beta revision; upstream has not published the documented v1 tag. + uses: readthedocs/upload-action@f1f5bac5fc97073927100367d8a5bb22587e1e16 + with: + token: ${{ secrets.READTHEDOCS_TOKEN }} + project-slug: ${{ vars.RTD_PROJECT }} + html: docs/_build/html + # Keep the existing latest URL. + version-name: latest diff --git a/.github/workflows/pr-documentation.yml b/.github/workflows/pr-documentation.yml index a40de1d..07f5bb9 100644 --- a/.github/workflows/pr-documentation.yml +++ b/.github/workflows/pr-documentation.yml @@ -6,7 +6,7 @@ on: permissions: actions: read - contents: write + contents: read pull-requests: read jobs: @@ -21,15 +21,30 @@ jobs: group: examples-rtd-pr-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }} cancel-in-progress: false steps: - # workflow_run uses trusted default-branch code. Never check out the PR - # or execute files from its artifacts in this credential-bearing job. + # workflow_run checks out trusted default-branch code, never PR code. + # Treat the downloaded artifacts as data and never execute their files. - uses: actions/checkout@v4 with: ref: ${{ github.sha }} persist-credentials: false - - name: Publish bundle and rebuild PR preview + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Validate artifacts and prepare PR preview + id: preview run: python3 scripts/publish_pr_docs.py env: GH_TOKEN: ${{ github.token }} - RTD_TOKEN: ${{ secrets.RTD_TOKEN }} - RTD_PROJECT: ${{ vars.RTD_PROJECT }} + - name: Upload PR preview to Read the Docs + if: steps.preview.outputs.ready == 'true' + # Same pinned beta revision as the main documentation workflow. + uses: readthedocs/upload-action@f1f5bac5fc97073927100367d8a5bb22587e1e16 + with: + token: ${{ secrets.READTHEDOCS_TOKEN }} + project-slug: ${{ vars.RTD_PROJECT }} + html: ${{ steps.preview.outputs.html }} + # workflow_run metadata describes the default branch, so override it + # with the validated PR metadata rather than relying on inference. + version-name: ${{ steps.preview.outputs.version }} + version-type: external + commit: ${{ steps.preview.outputs.commit }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3626cbe..3c7a42f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -177,29 +177,11 @@ plots and staging directories. ### CI and hosting -The documentation workflow resolves one execution lock and shares it across -parallel example jobs. It collects outputs from that same workflow run, runs -checks, builds Sphinx, and uploads HTML and a validated output bundle. - -Trusted runs on `main` can attach the bundle to a dedicated GitHub release keyed -by the full source commit and trigger a Read the Docs build. Read the Docs -installs `docs/requirements.txt`, fetches the exact matching bundle, and verifies -it before building; it does not run sampling. Freshness checks guard `latest` -builds, and there is no fallback to results from another commit. - -Pull requests run examples and build Sphinx at the PR head commit, matching -Read the Docs previews. After a successful run, the separate -`Publish PR documentation` workflow runs trusted code from the default branch. -It reads the completed run's artifact as data, checks its archive and commit, -publishes it under `docs-pr--`, and requests the PR preview. -PR code and artifacts are never executed in that publishing job. Read the Docs -then performs the same output provenance validation used for `latest`. - -Maintainers must configure the Read the Docs project, the repository variable -`RTD_PROJECT`, and the secret `RTD_TOKEN` before enabling this hosting workflow. -Keep credentials out of source files and pull request jobs. The configuration -uses standard Read the Docs hosting; private access requires a hosting service -and account configuration that supports it. +CI shares one execution lock across example jobs, validates their outputs, and +builds Sphinx. The official RTD upload action publishes main as `latest`. +PR previews, including forks, use a separate trusted workflow that validates +artifacts without executing PR code. Stale publications are skipped; no Git +tags or GitHub releases are created. ## Commit messages diff --git a/scripts/publish_docs.py b/scripts/publish_docs.py deleted file mode 100644 index ce32516..0000000 --- a/scripts/publish_docs.py +++ /dev/null @@ -1,138 +0,0 @@ -"""Trusted publication only: immutable-by-policy assets, then serialized RTD.""" - -from __future__ import annotations - -import json -import os -import subprocess -import time -import urllib.parse -import urllib.request -from pathlib import Path - -from docs_pipeline import ROOT, bundle, digest, git - - -def gh(*args: str) -> str: - """Call the GitHub CLI, failing on API or permission errors.""" - return subprocess.check_output(["gh", *args], cwd=ROOT, text=True).strip() - - -def rtd(path: str, method: str = "GET") -> dict: - """Call the documented Community API without logging credentials.""" - request = urllib.request.Request( - "https://app.readthedocs.org/api/v3/" + path, - headers={"Authorization": "Token " + os.environ["RTD_TOKEN"]}, - method=method, - ) - with urllib.request.urlopen(request, timeout=60) as response: - return json.load(response) - - -def wait_idle(project: str, deadline: float) -> None: - """Wait through the RTD upload before allowing another publication.""" - while rtd(f"projects/{project}/builds/?running=true")["count"]: - if time.monotonic() > deadline: - raise TimeoutError( - "RTD still running; inspect builds before retrying" - ) - time.sleep(20) - - -def publish_assets(path: Path, tag: str, target: str) -> None: - """Publish a bundle and checksum without replacing existing assets.""" - # Dedicated prereleases in the examples repository cannot become a library - # release or GitHub's latest software release. - view = subprocess.run( - ["gh", "release", "view", tag, "--json", "assets,isDraft"], - cwd=ROOT, - text=True, - capture_output=True, - ) - if view.returncode: - gh( - "release", - "create", - tag, - "--target", - target, - "--title", - tag, - "--notes", - f"Example documentation outputs: {tag}.", - "--prerelease", - "--latest=false", - "--draft", - ) - release = {"assets": [], "isDraft": True} - else: - release = json.loads(view.stdout) - names = {asset["name"] for asset in release["assets"]} - for asset in (path, Path(str(path) + ".sha256")): - if asset.name in names: - existing = ROOT / "build-docs" / "existing" - existing.mkdir(exist_ok=True) - gh( - "release", - "download", - tag, - "--pattern", - asset.name, - "--dir", - str(existing), - "--clobber", - ) - if digest(existing / asset.name) != digest(asset): - raise ValueError( - f"Refusing to replace different asset: {asset.name}" - ) - else: - gh("release", "upload", tag, str(asset)) - if release["isDraft"]: - gh("release", "edit", tag, "--draft=false", "--latest=false") - - -def build_version( - project: str, version: str, sha: str, deadline: float -) -> None: - """Trigger and monitor an RTD version, requiring the expected commit.""" - result = rtd(f"projects/{project}/versions/{version}/builds/", "POST") - build_id = result["build"]["id"] - while True: - build = rtd(f"projects/{project}/builds/{build_id}/") - if build["state"]["code"] in {"finished", "cancelled"}: - break - if time.monotonic() > deadline: - raise TimeoutError( - "RTD build timed out; inspect it before retrying" - ) - time.sleep(20) - if not build["success"] or build["commit"] != sha: - raise ValueError( - f"RTD build {build_id} failed or checked out another commit; " - "inspect RTD and rerun the workflow for the current commit" - ) - print(f"RTD build {build_id} succeeded for {sha}") - - -def main() -> None: - """Publish only main, verify assets, and monitor the triggered build.""" - sha = git("rev-parse", "HEAD") - if git("ls-remote", "origin", "refs/heads/main").split()[0] != sha: - print("Main has advanced; skipping stale publication") - return - if set(git("diff", "--name-only", "HEAD").splitlines()) - {"pixi.lock"}: - raise ValueError("Refusing publication from a modified checkout") - publish_assets(bundle(), f"docs-output-{sha}", sha) - # The workflow's non-cancelling concurrency lock covers this whole wait. - project = urllib.parse.quote(os.environ["RTD_PROJECT"], safe="") - deadline = time.monotonic() + 2400 - wait_idle(project, deadline) - if git("ls-remote", "origin", "refs/heads/main").split()[0] != sha: - print("Bundle uploaded; main advanced, skipping latest trigger") - return - build_version(project, "latest", sha, deadline) - - -if __name__ == "__main__": - main() diff --git a/scripts/publish_pr_docs.py b/scripts/publish_pr_docs.py index 5f57edb..aee81a9 100644 --- a/scripts/publish_pr_docs.py +++ b/scripts/publish_pr_docs.py @@ -1,4 +1,4 @@ -"""Publish PR bundles as data using only trusted default-branch code.""" +"""Prepare PR artifacts as data using only trusted default-branch code.""" from __future__ import annotations @@ -8,20 +8,23 @@ import json import os import re +import stat import subprocess import tarfile import tempfile -import time -import urllib.parse import zipfile from pathlib import Path -from docs_pipeline import REPOSITORY, ROOT, digest, git -from publish_docs import build_version, gh, publish_assets, wait_idle +from docs_pipeline import REPOSITORY, ROOT, digest MAX_BYTES = 128 * 1024 * 1024 +def gh(*args: str) -> str: + """Read GitHub data without executing files from the PR artifact.""" + return subprocess.check_output(["gh", *args], cwd=ROOT, text=True).strip() + + def api(path: str) -> dict | list: """Read GitHub metadata from the fixed examples repository.""" return json.loads(gh("api", f"repos/{REPOSITORY}/{path}")) @@ -88,18 +91,18 @@ def verify_bundle(path: Path, sha: str) -> None: raise ValueError("PR bundle manifest/commit/checksum mismatch") -def download_bundle(run_id: int, sha: str, destination: Path) -> Path: - """Read only the named artifact from this run into an isolated directory.""" +def download_artifact(run_id: int, name: str, destination: Path) -> Path: + """Download one bounded artifact from the exact completed workflow run.""" artifacts = api(f"actions/runs/{run_id}/artifacts?per_page=100") matches = [ item for item in artifacts["artifacts"] - if item["name"] == "validated-bundle" and not item["expired"] + if item["name"] == name and not item["expired"] ] if len(matches) != 1 or matches[0]["size_in_bytes"] > MAX_BYTES: raise ValueError("Missing, ambiguous or oversized PR artifact") artifact_id = int(matches[0]["id"]) - zipped = destination / "artifact.zip" + zipped = destination / f"{name}.zip" with zipped.open("wb") as stream: subprocess.run( [ @@ -113,6 +116,12 @@ def download_bundle(run_id: int, sha: str, destination: Path) -> Path: ) if zipped.stat().st_size > MAX_BYTES: raise ValueError("Oversized PR artifact download") + return zipped + + +def download_bundle(run_id: int, sha: str, destination: Path) -> Path: + """Validate the example provenance bundle from the completed run.""" + zipped = download_artifact(run_id, "validated-bundle", destination) path = destination / f"docs-outputs-{sha}.tar.gz" expected = {path.name, path.name + ".sha256"} with zipfile.ZipFile(zipped) as archive: @@ -132,8 +141,47 @@ def download_bundle(run_id: int, sha: str, destination: Path) -> Path: return path +def download_html(run_id: int, destination: Path) -> Path: + """Extract static HTML as data, rejecting unsafe or oversized archives.""" + zipped = download_artifact(run_id, "documentation-html", destination) + html = destination / "html" + with zipfile.ZipFile(zipped) as archive: + members = archive.infolist() + names = set() + if ( + len(members) > 10000 + or sum(member.file_size for member in members) > MAX_BYTES + ): + raise ValueError("Oversized PR HTML artifact") + # Validate all paths before writing. Never preserve executable modes, + # create links, or extract files into the trusted source checkout. + for member in members: + name = Path(member.filename) + mode = member.external_attr >> 16 + if ( + name.is_absolute() + or ".." in name.parts + or str(name) != member.filename.rstrip("/") + or "\\" in member.filename + or str(name) in names + or stat.S_IFMT(mode) not in {0, stat.S_IFREG, stat.S_IFDIR} + ): + raise ValueError("Unsafe PR HTML artifact") + names.add(str(name)) + for member in members: + target = html / member.filename + if member.is_dir(): + target.mkdir(parents=True, exist_ok=True) + else: + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(archive.read(member)) + if not (html / "index.html").is_file(): + raise ValueError("PR HTML artifact is missing index.html") + return html + + def main() -> None: - """Publish successful current PR runs, then request their RTD preview.""" + """Prepare a current PR's HTML and metadata for the official RTD action.""" event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text()) run_id = int(event["workflow_run"]["id"]) run = api(f"actions/runs/{run_id}") @@ -152,23 +200,21 @@ def main() -> None: print("PR closed or advanced; skipping stale publication") return number = int(pr["number"]) - with tempfile.TemporaryDirectory(prefix="pr-docs-") as tmp: - path = download_bundle(run_id, sha, Path(tmp)) - current = current_pr(sha) - if current is None or current["number"] != number: - print("PR changed; skipping stale publication") - return - # Tag only the trusted checkout; PR data has a separate release namespace. - publish_assets( - path, f"docs-pr-{number}-{sha}", git("rev-parse", "HEAD") - ) - project = urllib.parse.quote(os.environ["RTD_PROJECT"], safe="") - deadline = time.monotonic() + 2400 - wait_idle(project, deadline) - if current_pr(sha) is None: - print("Bundle uploaded; PR closed or advanced, skipping preview") + # Keep the HTML until the following action finishes; the hosted runner + # removes its temporary directory after the job. + directory = Path( + tempfile.mkdtemp(prefix="pr-docs-", dir=os.environ["RUNNER_TEMP"]) + ) + download_bundle(run_id, sha, directory) + html = download_html(run_id, directory) + current = current_pr(sha) + if current is None or current["number"] != number: + print("PR changed; skipping stale publication") return - build_version(project, str(number), sha, deadline) + with Path(os.environ["GITHUB_OUTPUT"]).open("a") as output: + output.write( + f"ready=true\nversion={number}\ncommit={sha}\nhtml={html}\n" + ) if __name__ == "__main__": diff --git a/tests/test_publish_docs.py b/tests/test_publish_docs.py deleted file mode 100644 index 204d07c..0000000 --- a/tests/test_publish_docs.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Check publication ordering and refusal paths without external writes.""" - -from __future__ import annotations - -import json -import sys -from pathlib import Path -from types import SimpleNamespace - -import pytest - -sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) -import publish_docs as publisher # noqa: E402 - - -@pytest.fixture -def publication( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> list[tuple]: - """Replace GitHub and RTD calls with a recorded successful publication.""" - calls: list[tuple] = [] - sha = "a" * 40 - monkeypatch.setattr(publisher, "ROOT", tmp_path) - monkeypatch.setattr( - publisher, "git", lambda *args: ("" if args[0] == "diff" else sha) - ) - bundle = tmp_path / "bundle.tar.gz" - bundle.write_bytes(b"validated outputs") - Path(str(bundle) + ".sha256").write_text("checksum") - monkeypatch.setattr(publisher, "bundle", lambda: bundle) - monkeypatch.setattr( - publisher.subprocess, - "run", - lambda *args, **kwargs: (SimpleNamespace(returncode=1, stdout="")), - ) - monkeypatch.setattr( - publisher, "gh", lambda *args: calls.append(args) or "" - ) - monkeypatch.setenv("RTD_PROJECT", "examples") - - def api(path: str, method: str = "GET") -> dict: - calls.append((method, path)) - if "running=true" in path: - return {"count": 0} - if method == "POST": - return {"build": {"id": 123}} - return {"state": {"code": "finished"}, "success": True, "commit": sha} - - monkeypatch.setattr(publisher, "rtd", api) - return calls - - -def test_publish_before_trigger(publication: list[tuple]) -> None: - publisher.main() - uploaded = publication.index( - ( - "release", - "edit", - "docs-output-" + "a" * 40, - "--draft=false", - "--latest=false", - ) - ) - triggered = publication.index( - ( - "POST", - "projects/examples/versions/latest/builds/", - ) - ) - assert uploaded < triggered - assert publication[-1] == ("GET", "projects/examples/builds/123/") - - -def test_stale_run_does_not_publish( - publication: list[tuple], monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setattr( - publisher, - "git", - lambda *args: ("b" * 40 if args[0] == "ls-remote" else "a" * 40), - ) - publisher.main() - assert not publication - - -def test_different_existing_asset_fails( - publication: list[tuple], tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - monkeypatch.setattr( - publisher.subprocess, - "run", - lambda *args, **kwargs: ( - SimpleNamespace( - returncode=0, - stdout=json.dumps( - { - "assets": [{"name": "bundle.tar.gz"}], - "isDraft": False, - } - ), - ) - ), - ) - (tmp_path / "build-docs").mkdir() - - def download(*args: str) -> str: - publication.append(args) - target = Path(args[args.index("--dir") + 1]) / "bundle.tar.gz" - target.write_bytes(b"different uploaded content") - return "" - - monkeypatch.setattr(publisher, "gh", download) - with pytest.raises(ValueError, match="Refusing to replace"): - publisher.main() - assert not any(call[0] == "POST" for call in publication) - - -def test_wait_idle_times_out(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(publisher, "rtd", lambda path: {"count": 1}) - monkeypatch.setattr(publisher.time, "monotonic", lambda: 10.0) - with pytest.raises(TimeoutError, match="RTD still running"): - publisher.wait_idle("examples", 0.0) diff --git a/tests/test_publish_pr_docs.py b/tests/test_publish_pr_docs.py index 3db4ff8..80e44c6 100644 --- a/tests/test_publish_pr_docs.py +++ b/tests/test_publish_pr_docs.py @@ -5,6 +5,7 @@ import hashlib import io import json +import stat import sys import tarfile import zipfile @@ -114,7 +115,8 @@ def publication(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> list: event = tmp_path / "event.json" event.write_text(json.dumps({"workflow_run": {"id": 123}})) monkeypatch.setenv("GITHUB_EVENT_PATH", str(event)) - monkeypatch.setenv("RTD_PROJECT", "examples") + monkeypatch.setenv("RUNNER_TEMP", str(tmp_path)) + monkeypatch.setenv("GITHUB_OUTPUT", str(tmp_path / "output")) monkeypatch.setattr( publisher, "api", @@ -127,7 +129,6 @@ def publication(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> list: }, ) monkeypatch.setattr(publisher, "current_pr", lambda sha: {"number": 17}) - monkeypatch.setattr(publisher, "git", lambda *args: "b" * 40) monkeypatch.setattr( publisher, "download_bundle", @@ -136,44 +137,40 @@ def publication(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> list: ) monkeypatch.setattr( publisher, - "publish_assets", - lambda path, tag, target: calls.append(("publish", tag, target)), - ) - monkeypatch.setattr( - publisher, - "wait_idle", - lambda *args: calls.append(("wait",)), - ) - monkeypatch.setattr( - publisher, - "build_version", - lambda project, version, sha, deadline: calls.append( - ("build", project, version, sha) - ), + "download_html", + lambda run_id, directory: calls.append(("html", run_id)) + or directory / "html", ) return calls -def test_publish_pr_before_preview(publication: list) -> None: +def test_prepare_pr_preview( + publication: list, monkeypatch: pytest.MonkeyPatch +) -> None: publisher.main() - assert publication == [ - ("download", 123, SHA), - ("publish", f"docs-pr-17-{SHA}", "b" * 40), - ("wait",), - ("build", "examples", "17", SHA), - ] + assert publication == [("download", 123, SHA), ("html", 123)] + output = Path(publisher.os.environ["GITHUB_OUTPUT"]) + metadata = dict( + line.split("=", 1) for line in output.read_text().splitlines() + ) + assert metadata["ready"] == "true" + assert metadata["version"] == "17" + assert metadata["commit"] == SHA + html = Path(metadata["html"]) + assert html.name == "html" + assert html.parent.parent == output.parent -@pytest.mark.parametrize("advance_at", [0, 1, 2]) +@pytest.mark.parametrize("advance_at", [0, 1]) def test_stale_or_closed_pr( publication: list, monkeypatch: pytest.MonkeyPatch, advance_at: int ) -> None: responses = iter([{"number": 17}] * advance_at + [None]) monkeypatch.setattr(publisher, "current_pr", lambda sha: next(responses)) publisher.main() - assert not any(call[0] == "build" for call in publication) - if advance_at < 2: - assert not any(call[0] == "publish" for call in publication) + assert not Path(publisher.os.environ["GITHUB_OUTPUT"]).exists() + if advance_at == 0: + assert not publication @pytest.mark.parametrize( @@ -211,3 +208,83 @@ def test_current_pr_uses_github_metadata( assert publisher.current_pr("b" * 40) is None records[0]["state"] = "closed" assert publisher.current_pr(SHA) is None + + +@pytest.mark.parametrize( + "failure", + [None, "path", "absolute", "symlink", "duplicate", "size", "index"], +) +def test_html_archive_validation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, failure: str | None +) -> None: + zipped = tmp_path / "html.zip" + with zipfile.ZipFile(zipped, "w") as archive: + archive.writestr("index.html", "Preview") + archive.writestr("_static/style.css", "body {}") + archive.writestr(".buildinfo", "Sphinx metadata") + if failure in {"path", "absolute"}: + archive.writestr( + "../escape" if failure == "path" else "/escape", "bad" + ) + elif failure == "symlink": + member = zipfile.ZipInfo("link") + member.create_system = 3 + member.external_attr = (stat.S_IFLNK | 0o777) << 16 + archive.writestr(member, "/etc/passwd") + elif failure == "duplicate": + with pytest.warns(UserWarning, match="Duplicate name"): + archive.writestr("index.html", "replacement") + if failure == "index": + with zipfile.ZipFile(zipped, "w") as archive: + archive.writestr("other.html", "No index") + if failure == "size": + monkeypatch.setattr(publisher, "MAX_BYTES", 1) + monkeypatch.setattr(publisher, "download_artifact", lambda *args: zipped) + if failure: + with pytest.raises(ValueError): + publisher.download_html(123, tmp_path) + else: + result = publisher.download_html(123, tmp_path) + assert (result / "index.html").read_text() == "Preview" + assert (result / "_static/style.css").read_text() == "body {}" + assert (result / ".buildinfo").is_file() + assert not (tmp_path / "escape").exists() + + +@pytest.mark.parametrize( + "failure", ["missing", "expired", "size", "duplicate"] +) +def test_invalid_artifact_metadata( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, failure: str +) -> None: + item = { + "id": 456, + "name": "documentation-html", + "expired": False, + "size_in_bytes": 10, + } + records = [item] + if failure == "missing": + records = [] + elif failure == "expired": + item["expired"] = True + elif failure == "size": + item["size_in_bytes"] = publisher.MAX_BYTES + 1 + elif failure == "duplicate": + records.append(item.copy()) + monkeypatch.setattr(publisher, "api", lambda path: {"artifacts": records}) + with pytest.raises(ValueError, match="PR artifact"): + publisher.download_artifact(123, "documentation-html", tmp_path) + + +def test_failed_bundle_does_not_prepare_upload( + publication: list, monkeypatch: pytest.MonkeyPatch +) -> None: + def fail(*args: object) -> None: + raise ValueError("Corrupt bundle") + + monkeypatch.setattr(publisher, "download_bundle", fail) + with pytest.raises(ValueError, match="Corrupt bundle"): + publisher.main() + assert not Path(publisher.os.environ["GITHUB_OUTPUT"]).exists() + assert not publication