From 598e48aa35639a46804945f0ad00bd8f8e3dc7b5 Mon Sep 17 00:00:00 2001 From: Ofek Lev Date: Tue, 20 Oct 2026 18:44:43 -0400 Subject: [PATCH] ci: remove obsolete documentation deployments --- .github/workflows/cleanup.yml | 54 ++++ README.md | 18 +- scripts/cleanup.py | 378 +++++++++++++++++++++++ tests/test_cleanup.py | 557 ++++++++++++++++++++++++++++++++++ tests/test_workflow_policy.py | 48 +++ 5 files changed, 1051 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/cleanup.yml create mode 100644 scripts/cleanup.py create mode 100644 tests/test_cleanup.py diff --git a/.github/workflows/cleanup.yml b/.github/workflows/cleanup.yml new file mode 100644 index 0000000..1e49091 --- /dev/null +++ b/.github/workflows/cleanup.yml @@ -0,0 +1,54 @@ +name: Clean up documentation deployments + +on: + schedule: + - cron: '7,17,27,37,47,57 * * * *' + workflow_dispatch: + +permissions: {} + +concurrency: + group: docs-cleanup + cancel-in-progress: false + +jobs: + cleanup: + if: github.repository == 'msgspec/docs-deploy' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + environment: docs-live + steps: + - name: Require the trusted publisher main branch + run: test "$GITHUB_REPOSITORY" = msgspec/docs-deploy && test "$GITHUB_REF" = refs/heads/main + + - name: Check out trusted publisher code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + repository: msgspec/docs-deploy + ref: main + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 + with: + python-version: '3.12' + + - name: Mint a source pull request reader token + id: reader + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 + with: + app-id: ${{ vars.DOCS_APP_ID }} + private-key: ${{ secrets.DOCS_APP_PRIVATE_KEY }} + owner: msgspec + repositories: msgspec + permission-contents: read + permission-pull-requests: read + + - name: Retain current production and open pull request aliases + env: + SOURCE_TOKEN: ${{ steps.reader.outputs.token }} + CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }} + CF_PAGES_TOKEN: ${{ secrets.CF_PAGES_TOKEN }} + run: python scripts/cleanup.py diff --git a/README.md b/README.md index b827963..d3b3b4d 100644 --- a/README.md +++ b/README.md @@ -51,20 +51,30 @@ The publisher downloads the ZIP by the exact artifact ID returned for the verifi Artifacts extract only into a fresh `$RUNNER_TEMP/docs-data/site` directory, outside the trusted publisher checkout. Validation rejects traversal, absolute and drive paths, backslashes, duplicate and conflicting paths, symbolic links, special files, encrypted entries, Git metadata, root `.github` entries, root `_worker.js` files or directories, and root `functions` entries. A root `index.html` is required. Resource limits are 512 MiB for the ZIP, 1 GiB for extracted files, and 100,000 entries. -The workflow never checks out or executes source code, artifact scripts, or artifact Wrangler configuration. Pinned Wrangler runs from the trusted publisher checkout with a fixed project and validated branch and commit arguments. Repository code must not add a Wrangler configuration that changes this boundary. Cloudflare credentials are supplied only to the deployment action. +The workflow never checks out or executes source code, artifact scripts, or artifact Wrangler configuration. Pinned Wrangler runs from the trusted publisher checkout with a fixed project and validated branch and commit arguments. Repository code must not add a Wrangler configuration that changes this boundary. Cloudflare credentials are supplied only to the deployment action and the trusted retention helper described below. Production checks out `cf-pages` only as snapshot data. Snapshot creation uses Git object and tree plumbing with content filters disabled and no commit hooks. This preserves exact file bytes, including files that an artifact `.gitignore` would otherwise hide. Snapshot commits retain GitHub Actions bot attribution. Their push uses only the publisher job's `GITHUB_TOKEN`, never an App token or a source write token. A normal push rejects unexpected concurrent snapshot changes. +## Deployment retention + +`.github/workflows/cleanup.yml` runs every ten minutes and accepts a `workflow_dispatch` on publisher `main` with no inputs. The Worker can dispatch the same workflow after publication completes or a pull request closes. These events only wake the sweep; the publisher resolves current Cloudflare deployments and source pull request state independently. Cleanup runs in its own concurrency group and uses the protected `docs-live` environment's `CF_ACCOUNT_ID` and `CF_PAGES_TOKEN`. Keep that environment restricted to publisher `main`, and grant the account-scoped Cloudflare token Pages read and write access, including deployment deletion. Its source App token has only Contents read and Pull requests read for `msgspec/msgspec`. + +The sweep retains the canonical production deployment currently identified by Cloudflare and the deployment currently serving `https://pr-N.msgspec.pages.dev` for each open source pull request targeting `main`. It deletes older production and preview deployments, previews of closed, merged, missing, or retargeted pull requests, and legacy previews from other branches. In-progress deployments remain until a later sweep observes a terminal state. The `cf-pages` snapshot remains operational data for production deduplication; cleanup does not change Git history or the snapshot branch. + +Before deleting anything, the helper snapshots every deployment page with `per_page=25`, validates all deployment identities, and reads every page of open source pull requests. Authentication failures, incomplete or inconsistent pagination, malformed responses, and ambiguous aliases stop the sweep. Before each deletion, it refreshes the candidate's stage and aliases and rechecks canonical production. Before deleting an aliased preview, it refreshes the complete authoritative source pull request list to protect reopened pull requests. API credentials stay on the fixed GitHub and Cloudflare API hosts; authenticated requests never follow redirects. The project is fixed to `msgspec`, and the account comes only from the trusted environment secret. + +Cleanup failures appear in the separate cleanup workflow and do not change a successful publication result or its `Docs preview` check. Review the failed run's error, repair credentials or unexpected provider metadata as needed, and rerun the failed job or dispatch `cleanup.yml` on `main` with no inputs. Successful earlier deletions remain effective, and retries resolve current state again. Concurrent publication, PR changes, and provider rate limits can defer work to a later sweep; the two providers do not offer an atomic cross-service state check. Deleted historical deployments cannot be used for rollback. Rebuild and publish a current successful source workflow when a removed preview is needed again. + ## Development -The helpers and tests use Python 3.12 and its standard library. The CI workflow runs API, routing, archive, result, Git snapshot, and workflow policy fixtures without credentials or network access. +The helpers and tests use Python 3.12 and its standard library. The CI workflow runs API, routing, archive, result, Git snapshot, retention, and workflow policy fixtures without credentials or network access. ```text python -m unittest discover -s tests -v -actionlint -no-color .github/workflows/publish.yml .github/workflows/ci.yml +actionlint -no-color .github/workflows/publish.yml .github/workflows/ci.yml .github/workflows/cleanup.yml zizmor --no-progress --format plain .github/workflows ``` -Tests cover malformed identities, stale attempts and heads, wrong repositories and workflows, fork association fallbacks and ambiguity, missing and expired artifacts, link-only reruns reusing the same run's build, artifacts predating the original run, digest mismatch, unsafe ZIP entries, production skip behavior, exact preview aliases, and snapshot filters and attribution. Action SHAs and Wrangler are pinned; update them deliberately and run all checks when changing the trust boundary. +Tests cover malformed identities, stale attempts and heads, wrong repositories and workflows, fork association fallbacks and ambiguity, missing and expired artifacts, link-only reruns reusing the same run's build, artifacts predating the original run, digest mismatch, unsafe ZIP entries, production skip behavior, exact preview aliases, and snapshot filters and attribution. Retention fixtures cover production rollback and PR reopening races, in-progress and retried deployments, historical and orphaned previews, pagination failures, authentication and deletion errors, null aliases, and credential-safe redirects. Action SHAs and Wrangler are pinned; update them deliberately and run all checks when changing the trust boundary. Local tests do not exercise live GitHub App installation, environment restrictions, cross-repository dispatch, artifact service redirects, Cloudflare credentials, or actual Pages upload. Validate those integrations after provisioning with a current successful source build and retain its publisher result as evidence. diff --git a/scripts/cleanup.py b/scripts/cleanup.py new file mode 100644 index 0000000..e178b70 --- /dev/null +++ b/scripts/cleanup.py @@ -0,0 +1,378 @@ +"""Remove obsolete Pages deployments using trusted, current provider metadata.""" + +import json +import os +import re +import sys +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode, urlsplit +from urllib.request import HTTPRedirectHandler, Request, build_opener + +SOURCE = "msgspec/msgspec" +PUBLISHER = "msgspec/docs-deploy" +PROJECT = "msgspec" +MAX_PAGES = 1000 +PER_PAGE = 25 +PR_BRANCH = re.compile(r"pr-([1-9][0-9]*)\Z") +PR_ALIAS = re.compile(r"https://pr-([1-9][0-9]*)\.msgspec\.pages\.dev\Z") + + +class Invalid(ValueError): + """A provider response or trusted configuration violated an invariant.""" + + +def require(condition, message): + if not condition: + raise Invalid(message) + + +def identifier(value): + require( + isinstance(value, str) and re.fullmatch(r"[A-Za-z0-9_-]{1,128}", value), + "The provider returned an invalid identifier.", + ) + return value + + +class NoRedirect(HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +def request_json(url, token, method="GET", github=False): + require(isinstance(token, str) and bool(token), "An API token is required.") + headers = { + "Authorization": "Bearer " + token, + "Accept": "application/json", + "User-Agent": "msgspec-docs-cleanup", + } + if github: + headers.update( + Accept="application/vnd.github+json", + **{"X-GitHub-Api-Version": "2022-11-28"}, + ) + try: + with build_opener(NoRedirect()).open( + Request(url, headers=headers, method=method), timeout=60 + ) as response: + require(response.status == 200, "The API returned an unexpected status.") + return json.load(response) + except HTTPError as exc: + raise Invalid(f"The {method} API request failed with HTTP {exc.code}.") from exc + except (URLError, json.JSONDecodeError, UnicodeDecodeError) as exc: + raise Invalid("The API request failed or returned invalid JSON.") from exc + + +class Source: + def __init__(self, token): + self.token = token + self.prefix = f"https://api.github.com/repos/{SOURCE}" + repo = request_json(self.prefix, self.token, github=True) + require( + isinstance(repo, dict) + and repo.get("full_name") == SOURCE + and type(repo.get("id")) is int + and repo["id"] > 0, + "GitHub returned an unexpected source repository.", + ) + self.repo_id = repo["id"] + + def open_prs(self): + numbers = set() + for page in range(1, MAX_PAGES + 1): + query = urlencode( + dict( + state="open", + base="main", + sort="created", + direction="asc", + per_page=100, + page=page, + ) + ) + items = request_json( + self.prefix + "/pulls?" + query, self.token, github=True + ) + require( + isinstance(items, list) and len(items) <= 100, + "GitHub returned an invalid pull request page.", + ) + for pr in items: + require( + isinstance(pr, dict), "GitHub returned an invalid pull request." + ) + number = pr.get("number") + base = pr.get("base") + require( + type(number) is int + and number > 0 + and number not in numbers + and pr.get("state") == "open" + and "merged_at" in pr + and pr["merged_at"] is None + and isinstance(base, dict) + and base.get("ref") == "main" + and isinstance(base.get("repo"), dict) + and base["repo"].get("id") == self.repo_id + and base["repo"].get("full_name") == SOURCE, + "GitHub returned an ambiguous or unexpected pull request identity.", + ) + numbers.add(number) + if len(items) < 100: + return numbers + raise Invalid("GitHub pagination exceeded the safety limit.") + + +class Cloudflare: + def __init__(self, account, token): + require( + isinstance(account, str) and re.fullmatch(r"[0-9a-f]{32}", account), + "CF_ACCOUNT_ID must be the trusted Cloudflare account ID.", + ) + self.token = token + self.prefix = ( + "https://api.cloudflare.com/client/v4/accounts/" + + account + + "/pages/projects/" + + PROJECT + ) + + def request(self, suffix="", method="GET"): + result = request_json(self.prefix + suffix, self.token, method) + require( + isinstance(result, dict) + and result.get("success") is True + and result.get("errors") == [] + and "result" in result, + "Cloudflare returned an unsuccessful or malformed API response.", + ) + return result + + def project(self): + project = self.request()["result"] + require( + isinstance(project, dict) + and project.get("name") == PROJECT + and project.get("production_branch") == "cf-pages", + "Cloudflare returned an unexpected project or production branch.", + ) + identifier(project.get("id")) + current = deployment(project.get("canonical_deployment"), project["id"]) + require( + current["environment"] == "production" + and current["latest_stage"]["name"] == "deploy" + and current["latest_stage"]["status"] == "success", + "Cloudflare did not identify a successful canonical production deployment.", + ) + return project["id"], current["id"] + + def deployments(self, project_id): + snapshot = {} + expected_total = None + for page in range(1, MAX_PAGES + 1): + result = self.request( + "/deployments?" + urlencode(dict(per_page=PER_PAGE, page=page)) + ) + items, info = result["result"], result.get("result_info") + require( + isinstance(items, list) + and len(items) <= PER_PAGE + and isinstance(info, dict) + and all( + type(info.get(key)) is int + for key in ( + "page", + "per_page", + "count", + "total_count", + "total_pages", + ) + ) + and info["page"] == page + and info["per_page"] == PER_PAGE + and info["count"] == len(items) + and info["total_count"] >= 0 + and info["total_pages"] + in { + (info["total_count"] + PER_PAGE - 1) // PER_PAGE, + max(1, (info["total_count"] + PER_PAGE - 1) // PER_PAGE), + }, + "Cloudflare returned inconsistent deployment pagination.", + ) + if expected_total is None: + expected_total = info["total_count"] + require( + info["total_count"] == expected_total, + "Cloudflare deployment pagination changed; retry the sweep.", + ) + for item in items: + item = deployment(item, project_id) + require( + item["id"] not in snapshot, "Duplicate deployment in pagination." + ) + snapshot[item["id"]] = item + if page >= info["total_pages"]: + require( + len(snapshot) == expected_total, + "Cloudflare returned an incomplete deployment snapshot.", + ) + return snapshot + require(len(items) == PER_PAGE, "Cloudflare returned an incomplete page.") + raise Invalid("Cloudflare pagination exceeded the safety limit.") + + def get(self, deployment_id, project_id): + item = deployment( + self.request("/deployments/" + identifier(deployment_id))["result"], + project_id, + ) + require(item["id"] == deployment_id, "Cloudflare returned another deployment.") + return item + + def delete(self, deployment_id, force): + suffix = "/deployments/" + identifier(deployment_id) + if force: + suffix += "?force=true" + self.request(suffix, "DELETE") + + +def deployment(item, project_id): + require(isinstance(item, dict), "Cloudflare returned an invalid deployment.") + identifier(item.get("id")) + trigger = item.get("deployment_trigger") + metadata = trigger.get("metadata") if isinstance(trigger, dict) else None + stage = item.get("latest_stage") + require( + item.get("project_name") == PROJECT + and item.get("project_id") == project_id + and item.get("environment") in ("preview", "production") + and isinstance(metadata, dict) + and isinstance(metadata.get("branch"), str) + and bool(metadata["branch"]) + and isinstance(stage, dict) + and stage.get("name") + in ("queued", "initialize", "clone_repo", "build", "deploy") + and stage.get("status") + in ("idle", "active", "success", "failure", "canceled", "skipped") + and "aliases" in item, + "Cloudflare returned an unexpected deployment identity or stage.", + ) + aliases = item["aliases"] + require( + aliases is None + or ( + isinstance(aliases, list) + and all(isinstance(alias, str) and bool(alias) for alias in aliases) + and len(set(aliases)) == len(aliases) + ), + "Cloudflare returned invalid deployment aliases.", + ) + branch = metadata["branch"] + for alias in aliases or []: + try: + parsed = urlsplit(alias) + except ValueError as exc: + raise Invalid( + "Cloudflare returned an invalid deployment alias URL." + ) from exc + require( + parsed.scheme == "https" + and parsed.hostname + and parsed.netloc == parsed.hostname + and not parsed.path + and not parsed.query + and not parsed.fragment, + "Cloudflare returned an invalid deployment alias URL.", + ) + if item["environment"] == "preview": + expected = f"https://{branch}.msgspec.pages.dev" + for alias in aliases or []: + require( + (not PR_BRANCH.fullmatch(branch) or alias == expected) + and (not PR_ALIAS.fullmatch(alias) or alias == expected), + "The preview branch and pull request alias have ambiguous identities.", + ) + return item + + +def terminal(item): + stage = item["latest_stage"] + return stage["status"] in ("failure", "canceled", "skipped") or ( + stage["name"] == "deploy" and stage["status"] == "success" + ) + + +def serving_open_pr(item, open_prs): + branch = item["deployment_trigger"]["metadata"]["branch"] + match = PR_BRANCH.fullmatch(branch) + return ( + item["environment"] == "preview" + and match is not None + and int(match[1]) in open_prs + and f"https://{branch}.msgspec.pages.dev" in (item["aliases"] or []) + ) + + +def sweep(cloudflare, source): + project_id, canonical = cloudflare.project() + snapshot = cloudflare.deployments(project_id) + require(canonical in snapshot, "The snapshot omitted canonical production.") + open_prs = source.open_prs() + aliases = set() + for item in snapshot.values(): + for alias in item["aliases"] or []: + require(alias not in aliases, "Several deployments claim the same alias.") + aliases.add(alias) + deleted = 0 + for original in snapshot.values(): + if ( + original["id"] == canonical + or not terminal(original) + or serving_open_pr(original, open_prs) + ): + continue + item = cloudflare.get(original["id"], project_id) + require( + item["environment"] == original["environment"] + and item["deployment_trigger"]["metadata"]["branch"] + == original["deployment_trigger"]["metadata"]["branch"], + "A deployment changed identity during cleanup.", + ) + if not terminal(item): + continue + force = item["environment"] == "preview" and bool(item["aliases"]) + if force: + open_prs = source.open_prs() + if serving_open_pr(item, open_prs): + continue + current_project_id, current_canonical = cloudflare.project() + require(current_project_id == project_id, "The Cloudflare project changed.") + if item["id"] == current_canonical: + continue + cloudflare.delete(item["id"], force) + deleted += 1 + print("Deleted deployment " + item["id"] + ".", flush=True) + result = dict( + listed=len(snapshot), deleted=deleted, retained=len(snapshot) - deleted + ) + print(json.dumps(result, sort_keys=True)) + return result + + +def main(): + require(len(sys.argv) == 1, "Cleanup accepts no arguments.") + require( + os.environ.get("GITHUB_REPOSITORY") == PUBLISHER + and os.environ.get("GITHUB_REF") == "refs/heads/main", + "Cleanup requires the trusted publisher main branch.", + ) + sweep( + Cloudflare(os.environ.get("CF_ACCOUNT_ID"), os.environ.get("CF_PAGES_TOKEN")), + Source(os.environ.get("SOURCE_TOKEN")), + ) + + +if __name__ == "__main__": + try: + main() + except Invalid as exc: + sys.exit(str(exc)) diff --git a/tests/test_cleanup.py b/tests/test_cleanup.py new file mode 100644 index 0000000..cc8bb4d --- /dev/null +++ b/tests/test_cleanup.py @@ -0,0 +1,557 @@ +import copy +import io +import json +import os +import unittest +from contextlib import redirect_stdout +from unittest.mock import patch +from urllib.error import HTTPError, URLError + +from scripts import cleanup + +PROJECT_ID = "project-id" +ACCOUNT_ID = "a" * 32 + + +def deployment(name="old", branch="cf-pages", environment="production", aliases=None): + return { + "id": name, + "project_id": PROJECT_ID, + "project_name": "msgspec", + "environment": environment, + "deployment_trigger": {"metadata": {"branch": branch}}, + "aliases": aliases, + "latest_stage": {"name": "deploy", "status": "success"}, + } + + +def preview(name="preview", branch="pr-42", aliased=True): + aliases = [f"https://{branch}.msgspec.pages.dev"] if aliased else [] + return deployment(name, branch, "preview", aliases) + + +def pull_request(number=42): + return { + "number": number, + "state": "open", + "merged_at": None, + "base": {"ref": "main", "repo": {"id": 10, "full_name": cleanup.SOURCE}}, + } + + +def page(items, number=1, total=None): + total = len(items) if total is None else total + return { + "success": True, + "errors": [], + "result": items, + "result_info": { + "page": number, + "per_page": 25, + "count": len(items), + "total_count": total, + "total_pages": (total + 24) // 25, + }, + } + + +class FakeSource: + def __init__(self, *states): + self.states = list(states or [set()]) + self.calls = 0 + + def open_prs(self): + self.calls += 1 + value = self.states.pop(0) if len(self.states) > 1 else self.states[0] + if isinstance(value, Exception): + raise value + return value + + +class FakeCloudflare: + def __init__(self, *items): + self.snapshot = {"live": deployment("live")} + self.snapshot.update((item["id"], item) for item in items) + self.current = copy.deepcopy(self.snapshot) + self.canonicals = ["live"] + self.calls = [] + self.fail_delete = False + + def project(self): + value = ( + self.canonicals.pop(0) if len(self.canonicals) > 1 else self.canonicals[0] + ) + self.calls.append(("project", value)) + return PROJECT_ID, value + + def deployments(self, project_id): + self.calls.append(("snapshot", project_id)) + return copy.deepcopy(self.snapshot) + + def get(self, deployment_id, project_id): + self.calls.append(("get", deployment_id)) + return copy.deepcopy(self.current[deployment_id]) + + def delete(self, deployment_id, force): + self.calls.append(("delete", deployment_id, force)) + if self.fail_delete: + raise cleanup.Invalid("Deletion failed.") + + +class RetentionTests(unittest.TestCase): + def sweep(self, cloudflare, source=None): + with redirect_stdout(io.StringIO()): + return cleanup.sweep(cloudflare, source or FakeSource({42})) + + def deleted(self, cloudflare): + return [call[1:] for call in cloudflare.calls if call[0] == "delete"] + + def test_only_current_production_and_serving_open_pr_previews_remain(self): + cloudflare = FakeCloudflare( + deployment(), + preview("open"), + preview("older", aliased=False), + preview("closed", "pr-43"), + preview("merged", "pr-44"), + preview("nonexistent", "pr-99999"), + preview("non-main", "pr-45"), + preview("legacy", "old-branch"), + ) + result = self.sweep(cloudflare) + self.assertEqual(result, dict(listed=9, deleted=7, retained=2)) + self.assertEqual( + self.deleted(cloudflare), + [ + ("old", False), + ("older", False), + ("closed", True), + ("merged", True), + ("nonexistent", True), + ("non-main", True), + ("legacy", True), + ], + ) + + def test_an_open_pr_does_not_keep_unaliased_history(self): + cloudflare = FakeCloudflare(preview(aliased=False)) + self.assertEqual(self.sweep(cloudflare)["deleted"], 1) + + def test_all_in_progress_stages_are_retained(self): + for stage in ("queued", "initialize", "clone_repo", "build", "deploy"): + for status in ("idle", "active", "success"): + if stage == "deploy" and status == "success": + continue + with self.subTest(stage=stage, status=status): + item = preview() + item["latest_stage"].update(name=stage, status=status) + cloudflare = FakeCloudflare(item) + self.assertEqual(self.sweep(cloudflare, FakeSource())["deleted"], 0) + + def test_failed_canceled_and_skipped_deployments_are_removed(self): + for status in ("failure", "canceled", "skipped"): + with self.subTest(status=status): + item = preview(aliased=False) + item["latest_stage"].update(name="build", status=status) + cloudflare = FakeCloudflare(item) + self.assertEqual(self.sweep(cloudflare)["deleted"], 1) + + def test_production_rollback_between_snapshot_and_delete_is_protected(self): + cloudflare = FakeCloudflare(deployment()) + cloudflare.canonicals = ["live", "old"] + self.assertEqual(self.sweep(cloudflare)["deleted"], 0) + self.assertEqual(cloudflare.calls[-1], ("project", "old")) + + def test_current_project_is_rechecked_immediately_before_each_delete(self): + cloudflare = FakeCloudflare(deployment(), preview("closed", "pr-43")) + self.sweep(cloudflare) + for index, call in enumerate(cloudflare.calls): + if call[0] == "delete": + self.assertEqual(cloudflare.calls[index - 1], ("project", "live")) + + def test_reopened_pr_is_protected_by_fresh_authoritative_source_state(self): + cloudflare = FakeCloudflare(preview()) + source = FakeSource(set(), {42}) + self.assertEqual(self.sweep(cloudflare, source)["deleted"], 0) + self.assertEqual(source.calls, 2) + + def test_new_alias_assigned_after_snapshot_is_protected(self): + cloudflare = FakeCloudflare(preview(aliased=False)) + cloudflare.current["preview"] = preview() + source = FakeSource(set(), {42}) + self.assertEqual(self.sweep(cloudflare, source)["deleted"], 0) + + def test_retried_in_progress_deployment_is_protected(self): + cloudflare = FakeCloudflare(deployment()) + cloudflare.current["old"]["latest_stage"]["status"] = "active" + self.assertEqual(self.sweep(cloudflare)["deleted"], 0) + + def test_initial_and_recheck_source_errors_fail_closed(self): + for states in ( + (cleanup.Invalid("Authentication failed."),), + (set(), cleanup.Invalid("Authentication failed.")), + ): + with self.subTest(states=states): + cloudflare = FakeCloudflare(preview()) + with self.assertRaisesRegex(cleanup.Invalid, "Authentication"): + self.sweep(cloudflare, FakeSource(*states)) + self.assertEqual(self.deleted(cloudflare), []) + + def test_ambiguous_aliases_fail_before_any_deletion(self): + cloudflare = FakeCloudflare(deployment(), preview("one"), preview("two")) + with self.assertRaisesRegex(cleanup.Invalid, "same alias"): + self.sweep(cloudflare) + self.assertEqual(self.deleted(cloudflare), []) + + def test_missing_canonical_in_snapshot_fails_closed(self): + cloudflare = FakeCloudflare(deployment()) + del cloudflare.snapshot["live"] + with self.assertRaisesRegex(cleanup.Invalid, "omitted canonical"): + self.sweep(cloudflare) + self.assertEqual(self.deleted(cloudflare), []) + + def test_mutated_identity_fails_closed(self): + cloudflare = FakeCloudflare(deployment()) + cloudflare.current["old"]["environment"] = "preview" + with self.assertRaisesRegex(cleanup.Invalid, "changed identity"): + self.sweep(cloudflare) + self.assertEqual(self.deleted(cloudflare), []) + + def test_delete_errors_stop_the_sweep_and_remain_visible(self): + cloudflare = FakeCloudflare(deployment("one"), deployment("two")) + cloudflare.fail_delete = True + with self.assertRaisesRegex(cleanup.Invalid, "Deletion failed"): + self.sweep(cloudflare) + self.assertEqual(self.deleted(cloudflare), [("one", False)]) + + +class CloudflareTests(unittest.TestCase): + def setUp(self): + self.api = cleanup.Cloudflare(ACCOUNT_ID, "cloudflare-token") + + def test_snapshot_collects_all_pages_before_returning(self): + items = [deployment(f"item-{number}") for number in range(26)] + with patch.object( + self.api, + "request", + side_effect=[page(items[:25], total=26), page(items[25:], 2, 26)], + ) as request: + self.assertEqual(len(self.api.deployments(PROJECT_ID)), 26) + self.assertEqual( + [call.args[0] for call in request.call_args_list], + ["/deployments?per_page=25&page=1", "/deployments?per_page=25&page=2"], + ) + + def test_empty_snapshot_accepts_zero_or_one_total_pages(self): + for total_pages in (0, 1): + result = page([]) + result["result_info"]["total_pages"] = total_pages + with patch.object(self.api, "request", return_value=result): + self.assertEqual(self.api.deployments(PROJECT_ID), {}) + + def test_second_page_failure_never_returns_partial_snapshot(self): + items = [deployment(f"item-{number}") for number in range(25)] + with patch.object( + self.api, + "request", + side_effect=[page(items, total=26), cleanup.Invalid("Listing failed.")], + ): + with self.assertRaisesRegex(cleanup.Invalid, "Listing failed"): + self.api.deployments(PROJECT_ID) + + def test_invalid_or_changing_pagination_fails_closed(self): + for field, value in ( + ("page", 2), + ("per_page", 100), + ("count", 2), + ("count", True), + ("total_count", 26), + ("total_count", -1), + ("total_pages", 2), + ): + with self.subTest(field=field, value=value): + result = page([deployment()]) + result["result_info"][field] = value + with patch.object(self.api, "request", return_value=result): + with self.assertRaises(cleanup.Invalid): + self.api.deployments(PROJECT_ID) + items = [deployment(f"item-{number}") for number in range(25)] + with patch.object( + self.api, + "request", + side_effect=[page(items, total=26), page([deployment()], 2, 27)], + ): + with self.assertRaisesRegex(cleanup.Invalid, "pagination changed"): + self.api.deployments(PROJECT_ID) + + def test_duplicate_ids_and_pagination_limit_fail_closed(self): + with patch.object(self.api, "request", return_value=page([deployment()] * 2)): + with self.assertRaisesRegex(cleanup.Invalid, "Duplicate"): + self.api.deployments(PROJECT_ID) + items = [deployment(f"item-{number}") for number in range(25)] + with ( + patch.object(cleanup, "MAX_PAGES", 1), + patch.object(self.api, "request", return_value=page(items, total=26)), + ): + with self.assertRaisesRegex(cleanup.Invalid, "safety limit"): + self.api.deployments(PROJECT_ID) + + def test_project_requires_current_successful_production_identity(self): + valid = dict( + id=PROJECT_ID, + name="msgspec", + production_branch="cf-pages", + canonical_deployment=deployment("live"), + ) + for change in ( + {"name": "other"}, + {"production_branch": "main"}, + {"canonical_deployment": None}, + {"canonical_deployment": preview()}, + {"canonical_deployment": dict(deployment(), latest_stage={})}, + ): + with self.subTest(change=change): + with patch.object( + self.api, "request", return_value={"result": dict(valid, **change)} + ): + with self.assertRaises(cleanup.Invalid): + self.api.project() + + def test_malformed_deployments_fail_closed(self): + for change in ( + {"id": "../other"}, + {"project_name": "other"}, + {"project_id": "other"}, + {"environment": "unknown"}, + {"deployment_trigger": None}, + {"latest_stage": None}, + {"latest_stage": {"name": "deploy", "status": "unknown"}}, + {"aliases": "https://pr-42.msgspec.pages.dev"}, + {"aliases": [False]}, + {"aliases": ["duplicate", "duplicate"]}, + ): + with self.subTest(change=change): + with self.assertRaises(cleanup.Invalid): + cleanup.deployment(dict(deployment(), **change), PROJECT_ID) + item = deployment() + del item["aliases"] + with self.assertRaises(cleanup.Invalid): + cleanup.deployment(item, PROJECT_ID) + + def test_null_and_empty_aliases_are_valid(self): + for aliases in (None, []): + cleanup.deployment(dict(preview(), aliases=aliases), PROJECT_ID) + + def test_malformed_alias_urls_fail_closed(self): + for alias in ( + "not-a-url", + "http://old.msgspec.pages.dev", + "https://user@old.msgspec.pages.dev", + "https://old.msgspec.pages.dev/path", + "https://old.msgspec.pages.dev?pr=42", + "https://old.msgspec.pages.dev#pr-42", + "https://[", + ): + with self.subTest(alias=alias): + with self.assertRaisesRegex(cleanup.Invalid, "alias URL"): + cleanup.deployment( + dict(preview(branch="old"), aliases=[alias]), PROJECT_ID + ) + + def test_mismatched_pr_branch_and_alias_fail_closed(self): + for item in ( + preview(branch="legacy"), + preview(), + ): + item["aliases"] = ["https://pr-43.msgspec.pages.dev"] + with self.assertRaisesRegex(cleanup.Invalid, "ambiguous"): + cleanup.deployment(item, PROJECT_ID) + + def test_only_fixed_project_host_and_validated_ids_receive_credentials(self): + with patch.object(cleanup, "request_json", return_value=page([])) as request: + self.api.delete("old", False) + self.api.delete("closed", True) + self.assertEqual( + [call.args for call in request.call_args_list], + [ + (self.api.prefix + "/deployments/old", "cloudflare-token", "DELETE"), + ( + self.api.prefix + "/deployments/closed?force=true", + "cloudflare-token", + "DELETE", + ), + ], + ) + self.assertEqual( + self.api.prefix, + "https://api.cloudflare.com/client/v4/accounts/" + + ACCOUNT_ID + + "/pages/projects/msgspec", + ) + for account in (None, "", "../other", "https://evil.example", "a" * 31): + with self.assertRaises(cleanup.Invalid): + cleanup.Cloudflare(account, "token") + with self.assertRaises(cleanup.Invalid): + self.api.delete("../other", False) + + def test_unsuccessful_or_malformed_envelopes_fail_closed(self): + for response in ( + [], + {}, + dict(page([]), success=False), + dict(page([]), errors=[{"code": 1}]), + dict(page([]), errors=None), + ): + with patch.object(cleanup, "request_json", return_value=response): + with self.assertRaises(cleanup.Invalid): + self.api.request() + + +class SourceTests(unittest.TestCase): + def source(self): + with patch.object( + cleanup, + "request_json", + return_value={"full_name": cleanup.SOURCE, "id": 10}, + ): + return cleanup.Source("source-reader-token") + + def test_open_main_prs_paginate_to_completion(self): + api = self.source() + prs = [pull_request(number) for number in range(1, 102)] + with patch.object( + cleanup, "request_json", side_effect=[prs[:100], prs[100:]] + ) as request: + self.assertEqual(api.open_prs(), set(range(1, 102))) + self.assertEqual(request.call_count, 2) + for call in request.call_args_list: + self.assertTrue(call.args[0].startswith(api.prefix + "/pulls?")) + self.assertIn("state=open&base=main", call.args[0]) + self.assertIn("per_page=100", call.args[0]) + self.assertEqual(call.args[1], "source-reader-token") + self.assertTrue(call.kwargs["github"]) + + def test_no_open_prs_is_authoritative_without_treating_404_as_closed(self): + api = self.source() + with patch.object(cleanup, "request_json", return_value=[]): + self.assertEqual(api.open_prs(), set()) + with patch.object( + cleanup, "request_json", side_effect=cleanup.Invalid("HTTP 404") + ): + with self.assertRaisesRegex(cleanup.Invalid, "HTTP 404"): + api.open_prs() + + def test_wrong_source_repository_fails_closed(self): + for result in ({}, [], {"full_name": "other/repo", "id": 10}): + with patch.object(cleanup, "request_json", return_value=result): + with self.assertRaises(cleanup.Invalid): + cleanup.Source("token") + + def test_wrong_or_ambiguous_pr_identity_fails_closed(self): + for path, value in ( + ("number", True), + ("number", 0), + ("state", "closed"), + ("merged_at", "2026-10-20T10:00:00Z"), + ("base", None), + ("base.ref", "release"), + ("base.repo.id", 99), + ("base.repo.full_name", "other/repo"), + ): + with self.subTest(path=path, value=value): + api = self.source() + pr = pull_request() + target = pr + parts = path.split(".") + for part in parts[:-1]: + target = target[part] + target[parts[-1]] = value + with patch.object(cleanup, "request_json", return_value=[pr]): + with self.assertRaises(cleanup.Invalid): + api.open_prs() + api = self.source() + for result in ({}, [None], [pull_request(), pull_request()]): + with patch.object(cleanup, "request_json", return_value=result): + with self.assertRaises(cleanup.Invalid): + api.open_prs() + + def test_late_page_error_and_limit_fail_instead_of_returning_partial_state(self): + api = self.source() + prs = [pull_request(number) for number in range(1, 101)] + with patch.object( + cleanup, + "request_json", + side_effect=[prs, cleanup.Invalid("Authentication failed.")], + ): + with self.assertRaisesRegex(cleanup.Invalid, "Authentication"): + api.open_prs() + with ( + patch.object(cleanup, "MAX_PAGES", 1), + patch.object(cleanup, "request_json", return_value=prs), + ): + with self.assertRaisesRegex(cleanup.Invalid, "safety limit"): + api.open_prs() + + +class TransportTests(unittest.TestCase): + def test_redirects_and_http_errors_never_become_absence(self): + for status in (301, 302, 307, 401, 403, 404, 429, 500): + with self.subTest(status=status): + error = HTTPError("https://api.example", status, "failure", {}, None) + with patch.object(cleanup, "build_opener") as opener: + opener.return_value.open.side_effect = error + with self.assertRaisesRegex(cleanup.Invalid, f"HTTP {status}"): + cleanup.request_json("https://api.example", "secret") + self.assertEqual(opener.return_value.open.call_count, 1) + self.assertIsInstance(opener.call_args.args[0], cleanup.NoRedirect) + self.assertIsNone( + cleanup.NoRedirect().redirect_request( + None, None, 302, "", {}, "https://evil" + ) + ) + + def test_network_errors_and_invalid_json_fail_closed(self): + with patch.object(cleanup, "build_opener") as opener: + opener.return_value.open.side_effect = URLError("unreachable") + with self.assertRaises(cleanup.Invalid): + cleanup.request_json("https://api.example", "secret") + with patch.object(cleanup, "build_opener") as opener: + response = opener.return_value.open.return_value.__enter__.return_value + response.status = 200 + response.read.return_value = b"not-json" + with self.assertRaises(cleanup.Invalid): + cleanup.request_json("https://api.example", "secret") + + def test_valid_response_and_scoped_authorization_headers(self): + with patch.object(cleanup, "build_opener") as opener: + response = opener.return_value.open.return_value.__enter__.return_value + response.status = 200 + response.read.return_value = json.dumps({"ok": True}) + self.assertEqual( + cleanup.request_json("https://api.github.com", "reader", github=True), + {"ok": True}, + ) + request = opener.return_value.open.call_args.args[0] + self.assertEqual(request.get_header("Authorization"), "Bearer reader") + self.assertEqual(request.get_header("X-github-api-version"), "2022-11-28") + for token in (None, ""): + with self.assertRaises(cleanup.Invalid): + cleanup.request_json("https://api.example", token) + + def test_main_requires_exact_publisher_main_and_no_inputs(self): + for repository, ref, argv in ( + (cleanup.SOURCE, "refs/heads/main", ["cleanup.py"]), + (cleanup.PUBLISHER, "refs/heads/other", ["cleanup.py"]), + (cleanup.PUBLISHER, "refs/heads/main", ["cleanup.py", "--project=other"]), + ): + with ( + patch.dict(os.environ, GITHUB_REPOSITORY=repository, GITHUB_REF=ref), + patch.object(cleanup.sys, "argv", argv), + patch.object(cleanup, "sweep") as sweep, + ): + with self.assertRaises(cleanup.Invalid): + cleanup.main() + sweep.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_workflow_policy.py b/tests/test_workflow_policy.py index 21d8f7f..04e041e 100644 --- a/tests/test_workflow_policy.py +++ b/tests/test_workflow_policy.py @@ -73,5 +73,53 @@ def test_no_source_checkout_or_artifact_execution(self): self.assertNotIn("postCommands:", job) +class CleanupWorkflowPolicyTests(unittest.TestCase): + def setUp(self): + self.workflow = (ROOT / ".github/workflows/cleanup.yml").read_text() + + def test_only_scheduled_and_no_input_manual_wakeups_are_supported(self): + self.assertIn("cron: '7,17,27,37,47,57 * * * *'", self.workflow) + self.assertIn(" workflow_dispatch:\n", self.workflow) + self.assertNotIn("inputs:", self.workflow) + self.assertNotIn("workflow_run:", self.workflow) + self.assertNotIn("pull_request", self.workflow) + self.assertNotIn("repository_dispatch", self.workflow) + + def test_cleanup_requires_expected_publisher_main_and_protected_environment(self): + self.assertIn( + "if: github.repository == 'msgspec/docs-deploy' && github.ref == 'refs/heads/main'", + self.workflow, + ) + self.assertIn('test "$GITHUB_REPOSITORY" = msgspec/docs-deploy', self.workflow) + self.assertIn('test "$GITHUB_REF" = refs/heads/main', self.workflow) + self.assertIn( + "repository: msgspec/docs-deploy\n ref: main", self.workflow + ) + self.assertIn("persist-credentials: false", self.workflow) + self.assertIn("environment: docs-live", self.workflow) + self.assertIn("group: docs-cleanup\n cancel-in-progress: false", self.workflow) + + def test_cleanup_token_is_read_only_source_scoped_and_cf_is_environment_scoped( + self, + ): + self.assertIn("permissions: {}", self.workflow) + self.assertIn("contents: read", self.workflow) + self.assertNotIn(": write", self.workflow) + self.assertIn("owner: msgspec\n repositories: msgspec", self.workflow) + self.assertIn("permission-contents: read", self.workflow) + self.assertIn("permission-pull-requests: read", self.workflow) + self.assertIn("CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}", self.workflow) + self.assertIn("CF_PAGES_TOKEN: ${{ secrets.CF_PAGES_TOKEN }}", self.workflow) + self.assertIn("run: python scripts/cleanup.py", self.workflow) + self.assertNotIn("download-artifact", self.workflow) + self.assertNotIn("source-run-id", self.workflow) + + def test_cleanup_cannot_change_the_publication_result(self): + publication = (ROOT / ".github/workflows/publish.yml").read_text() + self.assertNotIn("cleanup.py", publication) + self.assertNotIn("cleanup.yml", publication) + self.assertNotIn("docs-deployment", self.workflow) + + if __name__ == "__main__": unittest.main()