From 659d511583a5d346db00f3e433edd87b6b960df7 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Wed, 21 Oct 2026 22:35:29 -0700 Subject: [PATCH] feat: publish documentation from the dox release bundle --- .github/workflows/jekyll-gh-pages.yml | 54 +++++++- .gitignore | 2 + 404.html | 6 - README.md | 55 ++++++-- docs/index.html | 17 --- scripts/import_docs.py | 190 ++++++++++++++++++++++++++ scripts/record_check.py | 109 +++++++++++++++ scripts/test_import_docs.py | 146 ++++++++++++++++++++ scripts/test_record_check.py | 128 +++++++++++++++++ 9 files changed, 671 insertions(+), 36 deletions(-) create mode 100644 .gitignore delete mode 100644 docs/index.html create mode 100644 scripts/import_docs.py create mode 100644 scripts/record_check.py create mode 100644 scripts/test_import_docs.py create mode 100644 scripts/test_record_check.py diff --git a/.github/workflows/jekyll-gh-pages.yml b/.github/workflows/jekyll-gh-pages.yml index 67f93c6..66e31d9 100644 --- a/.github/workflows/jekyll-gh-pages.yml +++ b/.github/workflows/jekyll-gh-pages.yml @@ -3,7 +3,14 @@ name: Deploy fastled.io on: push: branches: ["gh-pages"] + schedule: + - cron: '47 10 * * *' workflow_dispatch: + inputs: + docs_only: + description: Deploy only if the upstream documentation publication changed + type: boolean + default: false permissions: contents: read @@ -13,9 +20,48 @@ concurrency: cancel-in-progress: false jobs: - build: + check: + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + publication: ${{ steps.docs.outputs.publication }} + changed: ${{ steps.docs.outputs.changed }} + deploy: ${{ steps.docs.outputs.deploy }} + release_id: ${{ steps.docs.outputs.release_id }} + tag: ${{ steps.docs.outputs.tag }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - name: Check the published documentation generation + id: docs + env: + DOCS_ONLY: ${{ inputs.docs_only || false }} + run: python3 scripts/import_docs.py check + + record-check: + needs: check runs-on: ubuntu-24.04 timeout-minutes: 5 + permissions: + contents: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - name: Record a successful documentation check at most monthly + env: + GH_TOKEN: ${{ github.token }} + RELEASE_ID: ${{ needs.check.outputs.release_id }} + RELEASE_TAG: ${{ needs.check.outputs.tag }} + RELEASE_CHANGED: ${{ needs.check.outputs.changed }} + run: python3 scripts/record_check.py + + build: + needs: check + if: needs.check.outputs.deploy == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 20 permissions: contents: read pages: read @@ -29,7 +75,11 @@ jobs: - name: Stage static site run: | mkdir _site - git archive --format=tar HEAD -- . ':!.github' ':!README.md' | tar -xf - -C _site + git archive --format=tar HEAD -- . ':!.github' ':!README.md' ':!scripts' ':!.gitignore' | tar -xf - -C _site + - name: Import the verified documentation publication + env: + PUBLICATION_JSON: ${{ needs.check.outputs.publication }} + run: python3 scripts/import_docs.py import --site _site - name: Upload artifact uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4 with: diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f3eff8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +_site/ +__pycache__/ diff --git a/404.html b/404.html index 966336f..825f589 100644 --- a/404.html +++ b/404.html @@ -4,12 +4,6 @@ Page not found — FastLED -

Page not found

diff --git a/README.md b/README.md index 1723fc5..215648c 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,57 @@ # fastled.io This repository hosts the static [FastLED website](https://fastled.io/). -Its deployment workflow uploads the tracked site files directly to GitHub Pages. +Its publishing workflow assembles the website and documentation into a GitHub +Pages deployment. Generated documentation is never committed to Git. ## API documentation -The API reference is published separately at https://fastled.github.io/dox/ by -[FastLED/dox](https://github.com/FastLED/dox), which checks daily for a new stable -FastLED release. This repository contains no generated Doxygen reference. +[FastLED/dox](https://github.com/FastLED/dox) builds the documentation from the +exact FastLED release tag. Its standalone site remains available at +https://fastled.github.io/dox/. -The homepage links directly to the new reference. `/docs/` redirects there; -the custom 404 page redirects old `/docs/...` deep links to corresponding pages, -preserving their query parameters and fragments. GitHub Pages serves those deep -redirects with HTTP 404; browsers follow the JavaScript redirect. +This website imports that same generated output under +https://fastled.io/docs/, so matching existing documentation URLs serve their +content directly. Navigation, search, styles, scripts, and images come from the +upstream bundle. The website does not run Doxygen or check out FastLED master. + +### Publication and update checks + +Dox publishes a small [publication manifest](https://fastled.github.io/dox/publication.json) +with the source release, publication generation, and SHA-256/size of a versioned +GitHub release asset. These downloads outlive the one-day Pages Actions artifact. +`scripts/import_docs.py` verifies the download and its embedded release metadata +before adding the documentation to `_site/docs/`. Import errors fail the workflow +before deployment, leaving the live website intact. + +The existing publishing workflow checks daily at **10:47 UTC**. A changed +publication triggers deployment, including an intentional rebuild of the same +release tag. Unchanged documentation checks skip build and deployment. Normal +website pushes still deploy the website with the latest published documentation. +Use **Run workflow → docs_only: true** for an immediate check that deploys only +if documentation changed. + +As in dox, `scripts/record_check.py` records a successful check at most every +30 days on `automation/release-checks`. This meaningful audit keeps the scheduled +workflow active during long periods without website edits. Its write permission +is restricted to the audit job and it does not publish the site. + +## Local verification + +```sh +uv run --no-project python -m unittest discover -s scripts -p 'test_*.py' +``` + +The importer uses Python 3.12+ and the standard library. A docs import reads the +validated manifest from `PUBLICATION_JSON` and stages into the directory passed +to `python3 scripts/import_docs.py import --site _site`. ## Site migration The non-documentation site was copied from `FastLED/FastLED`'s former `gh-pages` branch at `2c2c9e489231912576eed208caeeed1a3392792f`, preserving PaletteKnife's newer script fixes in this repository. Generated documentation and its history -were excluded. The Pages custom domain is `fastled.io`; DNS remains on GitHub -Pages. Migration history is tracked in -[FastLED/FastLED#4878](https://github.com/FastLED/FastLED/issues/4878). +were excluded. The Pages custom domain remains `fastled.io` with the existing +GitHub Pages DNS records. See +[FastLED/FastLED#4878](https://github.com/FastLED/FastLED/issues/4878) and +[issue #9](https://github.com/FastLED/fastled-fastled.github.io/issues/9). diff --git a/docs/index.html b/docs/index.html deleted file mode 100644 index b9ecd2e..0000000 --- a/docs/index.html +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - FastLED documentation has moved - - - - -

Open the FastLED API reference.

- diff --git a/scripts/import_docs.py b/scripts/import_docs.py new file mode 100644 index 0000000..4e57543 --- /dev/null +++ b/scripts/import_docs.py @@ -0,0 +1,190 @@ +"""Import a verified FastLED/dox publication into the website deployment.""" + +import argparse +import hashlib +import json +import os +import re +import tarfile +import tempfile +import urllib.error +import urllib.request +from pathlib import Path, PurePosixPath +from urllib.parse import urlencode + +UPSTREAM = "https://fastled.github.io/dox/publication.json" +CURRENT = "https://fastled.io/docs/publication.json" +MAX_ARCHIVE = 2 * 1024**3 +MAX_EXTRACTED = 10 * 1024**3 + + +def validate_manifest(manifest: dict) -> None: + try: + generation = manifest["generation"] + release = manifest["release"] + archive = manifest["archive"] + valid = ( + manifest["schema_version"] == 1 + and isinstance(generation, str) + and re.fullmatch(r"[0-9]+-[0-9]+", generation) + and release["generation"] == generation + and type(release["id"]) is int + and release["id"] > 0 + and re.fullmatch(r"v?[0-9]+\.[0-9]+\.[0-9]+", release["tag_name"]) + and re.fullmatch(r"[0-9a-f]{40}", release["source_sha"]) + and isinstance(release["doxygen_version"], str) + and release["doxygen_version"] + and archive["url"] + == f"https://github.com/FastLED/dox/releases/download/docs-{generation}/docs.tar.gz" + and re.fullmatch(r"[0-9a-f]{64}", archive["sha256"]) + and type(archive["size"]) is int + and 0 < archive["size"] <= MAX_ARCHIVE + ) + except (KeyError, TypeError): + valid = False + if not valid: + raise ValueError("Invalid documentation publication manifest") + + +def fetch_manifest(url: str, *, missing_ok: bool = False) -> dict | None: + request = urllib.request.Request(url, headers={"Cache-Control": "no-cache"}) + try: + with urllib.request.urlopen(request, timeout=60) as response: + data = response.read(65537) + except urllib.error.HTTPError as error: + if error.code == 404 and missing_ok: + return None + raise + if len(data) > 65536: + raise ValueError("Documentation manifest exceeds 64 KiB") + manifest = json.loads(data) + validate_manifest(manifest) + return manifest + + +def check_publication(upstream: str, current: str) -> tuple[dict, bool]: + manifest = fetch_manifest(upstream) + deployed = fetch_manifest(current, missing_ok=True) + return manifest, manifest != deployed + + +def download_archive(manifest: dict, destination: Path) -> None: + validate_manifest(manifest) + archive = manifest["archive"] + size = 0 + digest = hashlib.sha256() + with urllib.request.urlopen(archive["url"], timeout=60) as response: + with destination.open("wb") as output: + while chunk := response.read(1024 * 1024): + size += len(chunk) + if size > archive["size"]: + raise ValueError("Documentation download exceeds manifest size") + digest.update(chunk) + output.write(chunk) + if size != archive["size"] or digest.hexdigest() != archive["sha256"]: + raise ValueError("Documentation download checksum or size mismatch") + + +def stage_archive(archive: Path, site: Path, manifest: dict) -> None: + validate_manifest(manifest) + if archive.stat().st_size != manifest["archive"]["size"]: + raise ValueError("Documentation archive size mismatch") + with archive.open("rb") as source: + if ( + hashlib.file_digest(source, "sha256").hexdigest() + != manifest["archive"]["sha256"] + ): + raise ValueError("Documentation archive checksum mismatch") + site.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory( + prefix="dox-import-", dir=site.parent + ) as temporary: + staging = Path(temporary) / "docs" + staging.mkdir() + with tarfile.open(archive, "r:gz") as tar: + members = tar.getmembers() + total = 0 + for member in members: + path = PurePosixPath(member.name) + if ( + path.is_absolute() + or ".." in path.parts + or "\\" in member.name + or not (member.isfile() or member.isdir()) + or (member.isfile() and not path.parts) + ): + raise ValueError( + f"Unsafe documentation archive entry: {member.name}" + ) + if member.isfile(): + total += member.size + if total > MAX_EXTRACTED: + raise ValueError( + "Documentation archive expands beyond Pages size limit" + ) + tar.extractall(staging, members=members, filter="data") + actual_release = json.loads((staging / "release.json").read_text()) + if actual_release != manifest["release"]: + raise ValueError("Archive release metadata does not match publication") + for required in ("index.html", "annotated.html", "search/search.js"): + path = staging / required + if not path.is_file() or path.stat().st_size == 0: + raise ValueError(f"Missing required documentation output: {required}") + (staging / "publication.json").write_text(json.dumps(manifest, indent=2) + "\n") + destination = site / "docs" + backup = Path(temporary) / "previous" + if destination.exists(): + destination.rename(backup) + try: + staging.rename(destination) + except OSError: + if backup.exists(): + backup.rename(destination) + raise + print( + f"Staged FastLED {actual_release['tag_name']} publication {manifest['generation']}" + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=("check", "import")) + parser.add_argument("--site", type=Path, default=Path("_site")) + args = parser.parse_args() + if args.command == "check": + query = urlencode( + { + "run": os.environ["GITHUB_RUN_ID"], + "attempt": os.environ["GITHUB_RUN_ATTEMPT"], + } + ) + manifest, changed = check_publication( + UPSTREAM + "?" + query, CURRENT + "?" + query + ) + docs_only = os.environ["GITHUB_EVENT_NAME"] == "schedule" or ( + os.environ.get("DOCS_ONLY") == "true" + ) + outputs = { + "publication": json.dumps(manifest, separators=(",", ":")), + "changed": str(changed).lower(), + "deploy": str(changed or not docs_only).lower(), + "release_id": str(manifest["release"]["id"]), + "tag": manifest["release"]["tag_name"], + } + with Path(os.environ["GITHUB_OUTPUT"]).open("a") as output: + for key, value in outputs.items(): + output.write(f"{key}={value}\n") + print( + f"Upstream publication {manifest['generation']}; update required: {outputs['deploy']}" + ) + else: + manifest = json.loads(os.environ["PUBLICATION_JSON"]) + validate_manifest(manifest) + with tempfile.TemporaryDirectory(prefix="dox-download-") as temporary: + archive = Path(temporary) / "docs.tar.gz" + download_archive(manifest, archive) + stage_archive(archive, args.site, manifest) + + +if __name__ == "__main__": + main() diff --git a/scripts/record_check.py b/scripts/record_check.py new file mode 100644 index 0000000..98e8d3b --- /dev/null +++ b/scripts/record_check.py @@ -0,0 +1,109 @@ +"""Record a successful release check monthly without rebuilding documentation.""" + +import json +import os +import urllib.error +import urllib.request +from datetime import datetime, timedelta, timezone + +BRANCH = "automation/release-checks" + + +def record_check(api, repository, release_id, tag, changed, run_url, now): + # Authenticate against the repository before treating a missing ref as new. + api("GET", "") + ref = api("GET", f"git/ref/heads/{BRANCH}") + previous = ref["object"]["sha"] if ref else None + if previous: + commit = api("GET", f"git/commits/{previous}") + committed = datetime.fromisoformat( + commit["committer"]["date"].replace("Z", "+00:00") + ) + if now - committed < timedelta(days=30): + print("Release check audit is current; no repository write needed.") + return False + + snapshot = { + "checked_at": now.isoformat().replace("+00:00", "Z"), + "release_id": int(release_id), + "tag": tag, + "changed": changed, + "run_url": run_url, + } + # A tree with no base_tree contains only this small audit, never site HTML. + tree = api( + "POST", + "git/trees", + { + "tree": [ + { + "path": "release-check.json", + "mode": "100644", + "type": "blob", + "content": json.dumps(snapshot, indent=2) + "\n", + } + ] + }, + ) + commit = api( + "POST", + "git/commits", + { + "message": "chore: record successful release check", + "tree": tree["sha"], + "parents": [previous] if previous else [], + }, + ) + if previous: + api("PATCH", f"git/refs/heads/{BRANCH}", {"sha": commit["sha"], "force": False}) + else: + api("POST", "git/refs", {"ref": f"refs/heads/{BRANCH}", "sha": commit["sha"]}) + print(f"Recorded successful release check on {repository}:{BRANCH}.") + return True + + +class GitHubAPI: + def __init__(self, repository, token): + self.base = f"{os.environ.get('GITHUB_API_URL', 'https://api.github.com')}/repos/{repository}" + self.token = token + + def __call__(self, method, path, body=None): + request = urllib.request.Request( + self.base + ("/" + path if path else ""), + data=json.dumps(body).encode() if body is not None else None, + method=method, + headers={ + "Authorization": f"Bearer {self.token}", + "Accept": "application/vnd.github+json", + "Content-Type": "application/json", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + return json.load(response) + except urllib.error.HTTPError as error: + if ( + error.code == 404 + and method == "GET" + and path == f"git/ref/heads/{BRANCH}" + ): + return None + raise + + +def main(): + repository = os.environ["GITHUB_REPOSITORY"] + record_check( + GitHubAPI(repository, os.environ["GH_TOKEN"]), + repository, + os.environ["RELEASE_ID"], + os.environ["RELEASE_TAG"], + os.environ["RELEASE_CHANGED"] == "true", + f"{os.environ['GITHUB_SERVER_URL']}/{repository}/actions/runs/{os.environ['GITHUB_RUN_ID']}", + datetime.now(timezone.utc), + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/test_import_docs.py b/scripts/test_import_docs.py new file mode 100644 index 0000000..91bea3c --- /dev/null +++ b/scripts/test_import_docs.py @@ -0,0 +1,146 @@ +"""Exercise real documentation archives and HTTP publication checks.""" + +import hashlib +import io +import json +import tarfile +import tempfile +import threading +import unittest +from functools import partial +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +from import_docs import check_publication, stage_archive, validate_manifest + + +class DocumentationImportTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.site = self.root / "site" + (self.site / "docs").mkdir(parents=True) + (self.site / "docs/index.html").write_text("old redirect") + self.release = { + "id": 416153555, + "tag_name": "3.10.8", + "source_sha": "d7402f1294e4548d9f8317f76afad37afaddb237", + "doxygen_version": "1.13.2", + "generation": "123-1", + } + self.manifest = { + "schema_version": 1, + "generation": "123-1", + "release": self.release, + "archive": { + "url": "https://github.com/FastLED/dox/releases/download/docs-123-1/docs.tar.gz", + "sha256": "", + "size": 0, + }, + } + self.archive = self.root / "docs.tar.gz" + self.make_archive() + + def make_archive(self, extra=None): + files = { + "./index.html": b"

FastLED documentation

", + "./annotated.html": b"

Classes

", + "./search/search.js": b"search works", + "./release.json": json.dumps(self.release).encode(), + } + with tarfile.open(self.archive, "w:gz") as tar: + for name, content in files.items(): + entry = tarfile.TarInfo(name) + entry.size = len(content) + tar.addfile(entry, io.BytesIO(content)) + if extra is not None: + tar.addfile(extra, io.BytesIO(b"bad") if extra.isfile() else None) + self.manifest["archive"]["sha256"] = hashlib.sha256( + self.archive.read_bytes() + ).hexdigest() + self.manifest["archive"]["size"] = self.archive.stat().st_size + + def test_import_replaces_redirect_and_preserves_site(self): + (self.site / "index.html").write_text("homepage") + stage_archive(self.archive, self.site, self.manifest) + self.assertIn( + "FastLED documentation", (self.site / "docs/index.html").read_text() + ) + self.assertEqual((self.site / "index.html").read_text(), "homepage") + self.assertEqual( + json.loads((self.site / "docs/publication.json").read_text()), self.manifest + ) + self.assertEqual( + json.loads((self.site / "docs/release.json").read_text()), self.release + ) + self.assertTrue((self.site / "docs/search/search.js").is_file()) + + def test_checksum_failure_preserves_existing_docs(self): + self.archive.write_bytes(self.archive.read_bytes() + b"tampered") + with self.assertRaises(ValueError): + stage_archive(self.archive, self.site, self.manifest) + self.assertEqual((self.site / "docs/index.html").read_text(), "old redirect") + + def test_paths_and_links_cannot_escape_docs(self): + for name, kind in [("../outside", tarfile.REGTYPE), ("link", tarfile.SYMTYPE)]: + with self.subTest(name=name): + extra = tarfile.TarInfo(name) + extra.type = kind + extra.linkname = "/tmp/outside" + extra.size = 3 if kind == tarfile.REGTYPE else 0 + self.make_archive(extra) + with self.assertRaises(ValueError): + stage_archive(self.archive, self.site, self.manifest) + self.assertFalse((self.root / "outside").exists()) + self.assertEqual( + (self.site / "docs/index.html").read_text(), "old redirect" + ) + + def test_source_metadata_mismatch_preserves_existing_docs(self): + self.manifest["release"] = {**self.release, "source_sha": "0" * 40} + with self.assertRaises(ValueError): + stage_archive(self.archive, self.site, self.manifest) + self.assertEqual((self.site / "docs/index.html").read_text(), "old redirect") + + def test_manifest_rejects_foreign_asset_and_generation_mismatch(self): + validate_manifest(self.manifest) + bad = json.loads(json.dumps(self.manifest)) + bad["archive"]["url"] = "https://example.org/docs.tar.gz" + with self.assertRaises(ValueError): + validate_manifest(bad) + bad = json.loads(json.dumps(self.manifest)) + bad["release"]["generation"] = "124-1" + with self.assertRaises(ValueError): + validate_manifest(bad) + + def test_actual_http_checks_skip_unchanged_and_detect_same_tag_rebuild(self): + handler = partial(SimpleHTTPRequestHandler, directory=str(self.root)) + server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + self.addCleanup(server.server_close) + self.addCleanup(server.shutdown) + base = f"http://127.0.0.1:{server.server_port}" + (self.root / "upstream.json").write_text(json.dumps(self.manifest)) + source, changed = check_publication( + base + "/upstream.json", base + "/missing.json" + ) + self.assertTrue(changed) + self.assertEqual(source, self.manifest) + (self.root / "current.json").write_text(json.dumps(self.manifest)) + _, changed = check_publication(base + "/upstream.json", base + "/current.json") + self.assertFalse(changed) + updated = json.loads(json.dumps(self.manifest)) + updated["generation"] = updated["release"]["generation"] = "123-2" + updated["archive"]["url"] = updated["archive"]["url"].replace("123-1", "123-2") + (self.root / "upstream.json").write_text(json.dumps(updated)) + _, changed = check_publication(base + "/upstream.json", base + "/current.json") + self.assertTrue(changed) + (self.root / "current.json").write_text("not JSON") + with self.assertRaises(ValueError): + check_publication(base + "/upstream.json", base + "/current.json") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_record_check.py b/scripts/test_record_check.py new file mode 100644 index 0000000..565dd60 --- /dev/null +++ b/scripts/test_record_check.py @@ -0,0 +1,128 @@ +"""Exercise audit branch updates without network access.""" + +import json +import unittest +import urllib.error +from datetime import datetime, timedelta, timezone +from unittest.mock import patch + +from record_check import BRANCH, GitHubAPI, record_check + + +class AuditTests(unittest.TestCase): + def setUp(self): + self.now = datetime(2026, 10, 21, tzinfo=timezone.utc) + self.calls = [] + self.responses = {} + + def api(self, method, path, body=None): + self.calls.append((method, path, body)) + return self.responses[(method, path)] + + def run_check(self): + return record_check( + self.api, + "FastLED/dox", + "416153555", + "3.10.8", + False, + "https://github.com/FastLED/dox/actions/runs/123", + self.now, + ) + + def prepare_write(self): + self.responses["POST", "git/trees"] = {"sha": "tree123"} + self.responses["POST", "git/commits"] = {"sha": "commit123"} + + def test_missing_branch_creates_orphan_snapshot(self): + self.responses["GET", ""] = {"full_name": "FastLED/dox"} + self.responses["GET", "git/ref/heads/automation/release-checks"] = None + self.prepare_write() + self.responses["POST", "git/refs"] = {} + self.assertTrue(self.run_check()) + tree = next(body for method, path, body in self.calls if path == "git/trees") + self.assertEqual(len(tree["tree"]), 1) + self.assertEqual(tree["tree"][0]["path"], "release-check.json") + snapshot = json.loads(tree["tree"][0]["content"]) + self.assertEqual(snapshot["release_id"], 416153555) + self.assertEqual(snapshot["tag"], "3.10.8") + self.assertFalse(snapshot["changed"]) + commit = next( + body for method, path, body in self.calls if path == "git/commits" + ) + self.assertEqual(commit["parents"], []) + self.assertEqual( + self.calls[-1], + ( + "POST", + "git/refs", + { + "ref": "refs/heads/automation/release-checks", + "sha": "commit123", + }, + ), + ) + self.assertEqual(self.calls[0], ("GET", "", None)) + + def existing_branch(self, age): + self.responses["GET", ""] = {"full_name": "FastLED/dox"} + self.responses["GET", "git/ref/heads/automation/release-checks"] = { + "object": {"sha": "previous"}, + } + self.responses["GET", "git/commits/previous"] = { + "committer": {"date": (self.now - age).isoformat()}, + } + + def test_recent_snapshot_does_not_write(self): + self.existing_branch(timedelta(days=29, hours=23, minutes=59)) + self.assertFalse(self.run_check()) + self.assertTrue(all(method == "GET" for method, _, _ in self.calls)) + + def test_thirty_day_snapshot_updates_without_force(self): + self.existing_branch(timedelta(days=30)) + self.prepare_write() + self.responses["PATCH", "git/refs/heads/automation/release-checks"] = {} + self.assertTrue(self.run_check()) + commit = next( + body for method, path, body in self.calls if path == "git/commits" + ) + self.assertEqual(commit["parents"], ["previous"]) + self.assertEqual( + self.calls[-1], + ( + "PATCH", + "git/refs/heads/automation/release-checks", + {"sha": "commit123", "force": False}, + ), + ) + + def test_authentication_error_prevents_creation(self): + # An unsuccessful repository lookup raises before any missing-ref handling. + with self.assertRaises(KeyError): + self.run_check() + self.assertEqual(self.calls, [("GET", "", None)]) + + +class APIErrorTests(unittest.TestCase): + def test_only_missing_audit_ref_is_optional(self): + api = GitHubAPI("FastLED/dox", "fixture-token") + for method, path, status, optional in ( + ("GET", f"git/ref/heads/{BRANCH}", 404, True), + ("GET", "", 404, False), + ("GET", f"git/ref/heads/{BRANCH}", 403, False), + ("POST", "git/refs", 404, False), + ): + with self.subTest(method=method, path=path, status=status): + error = urllib.error.HTTPError( + "https://api.github.com", status, "fixture", {}, None + ) + with patch("urllib.request.urlopen", side_effect=error): + if optional: + self.assertIsNone(api(method, path)) + else: + with self.assertRaises(urllib.error.HTTPError): + api(method, path) + + +if __name__ == "__main__": + unittest.main()