From 200d9181dbb12b72b2a6330e4f572e2fe38e4824 Mon Sep 17 00:00:00 2001 From: Jason Song Date: Tue, 22 Sep 2026 10:29:50 +0800 Subject: [PATCH 1/3] fix: deploy tagged API docs through main and verify published content --- .github/workflows/deploy-docs.yml | 52 ++++++++++++--- .github/workflows/dispatch-docs.yml | 23 +++++++ README.md | 14 ++++- scripts/build-docs.sh | 4 ++ scripts/verify-docs.py | 86 +++++++++++++++++++++++++ tests/test_docs_deployment.py | 98 +++++++++++++++++++++++++++++ 6 files changed, 264 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/dispatch-docs.yml create mode 100644 scripts/verify-docs.py create mode 100644 tests/test_docs_deployment.py diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 1373a27..3920623 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -6,16 +6,22 @@ on: - apollo-openapi.yaml - redocly.yaml - scripts/build-docs.sh + - scripts/verify-docs.py - package.json - package-lock.json - .github/workflows/deploy-docs.yml - tags: ['v*'] - workflow_dispatch: {} + workflow_dispatch: + inputs: + expected_tag: + description: Release tag that must be present in the published site + required: false + type: string permissions: contents: read - pages: write - id-token: write + +env: + DOCS_URL: https://openapi.apolloconfig.com concurrency: group: pages @@ -23,12 +29,13 @@ concurrency: jobs: build: + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest + outputs: + source_sha: ${{ steps.expected.outputs.source_sha }} + checks: ${{ steps.expected.outputs.checks }} steps: - # Always build from main, never from the triggering ref: on a v* tag push - # github.ref is the tag, and site/next would then be built from the tag's - # spec instead of the current main HEAD. Released versions are read out of - # git history (git show :spec), so they are unaffected by this. + # Build next from main and released versions from their tags. - uses: actions/checkout@v4 with: ref: main @@ -38,7 +45,17 @@ jobs: with: node-version: "20" - run: npm ci - - run: ./scripts/build-docs.sh + - id: docs + run: ./scripts/build-docs.sh + - name: Record expected published content + id: expected + env: + LATEST_TAG: ${{ steps.docs.outputs.latest_tag }} + EXPECTED_TAG: ${{ inputs.expected_tag }} + run: | + echo "source_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + python3 scripts/verify-docs.py manifest site \ + --version "$LATEST_TAG" --version "$EXPECTED_TAG" >> "$GITHUB_OUTPUT" - uses: actions/upload-pages-artifact@v3 with: path: site @@ -46,9 +63,24 @@ jobs: deploy: needs: build runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + pages: write + id-token: write environment: name: github-pages - url: ${{ steps.deployment.outputs.page_url }} + url: https://openapi.apolloconfig.com steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.build.outputs.source_sha }} + persist-credentials: false - id: deployment uses: actions/deploy-pages@v4 + - name: Verify published content + env: + EXPECTED_CHECKS: ${{ needs.build.outputs.checks }} + run: | + python3 scripts/verify-docs.py verify \ + --url "$DOCS_URL" --checks "$EXPECTED_CHECKS" diff --git a/.github/workflows/dispatch-docs.yml b/.github/workflows/dispatch-docs.yml new file mode 100644 index 0000000..4c38b47 --- /dev/null +++ b/.github/workflows/dispatch-docs.yml @@ -0,0 +1,23 @@ +name: Dispatch API Docs Deployment +on: + push: + tags: ['v*'] + +permissions: + actions: write + +jobs: + dispatch: + if: github.event.deleted == false + runs-on: ubuntu-latest + steps: + # Tag deployments can report success while Pages keeps serving old content. + # https://github.com/actions/deploy-pages/issues/383 + - name: Start a deployment from main + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} + run: | + gh workflow run deploy-docs.yml \ + --repo "$GITHUB_REPOSITORY" --ref main \ + --raw-field "expected_tag=$RELEASE_TAG" diff --git a/README.md b/README.md index bb7b487..9d39ab2 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # apollo-openapi ![OpenAPI](https://img.shields.io/badge/spec-OpenAPI%203.0.1-blue) -[![Docs](https://img.shields.io/badge/docs-API%20reference-blue)](https://apolloconfig.github.io/apollo-openapi/) +[![Docs](https://img.shields.io/badge/docs-API%20reference-blue)](https://openapi.apolloconfig.com/) This repository maintains the Apollo OpenAPI contract. The source of truth is [`apollo-openapi.yaml`](apollo-openapi.yaml). @@ -11,9 +11,17 @@ This repository maintains the Apollo OpenAPI contract. The source of truth is Browse the rendered API reference for every released version (plus `next` for the unreleased `main` HEAD): -**https://apolloconfig.github.io/apollo-openapi/** +**https://openapi.apolloconfig.com/** -See [all versions](https://apolloconfig.github.io/apollo-openapi/versions.html). +See [all versions](https://openapi.apolloconfig.com/versions.html). + +Documentation changes on `main` deploy automatically. A `v*` tag push starts +the deployment workflow on `main` using `workflow_dispatch`, avoiding the +[Pages tag deployment issue](https://github.com/actions/deploy-pages/issues/383). +The deployment verifies the published version index, homepage, latest version, +and the triggering release version against the generated files. To retry a +release deployment, run **Deploy API Docs** on `main` with `expected_tag` set +to the release tag. Generated code is treated as a temporary verification artifact, not as maintained source code or an official Apollo SDK. Apollo Portal pins a released diff --git a/scripts/build-docs.sh b/scripts/build-docs.sh index 50e05e1..676bdc6 100755 --- a/scripts/build-docs.sh +++ b/scripts/build-docs.sh @@ -111,4 +111,8 @@ echo "Generating versions.html..." echo '' } > "$SITE_DIR/versions.html" +if [ -n "${GITHUB_OUTPUT:-}" ]; then + echo "latest_tag=$LATEST_TAG" >> "$GITHUB_OUTPUT" +fi + echo "Done. Built $((${#BUILT_TAGS[@]} + 1)) versions (${#BUILT_TAGS[@]} tags + next)." diff --git a/scripts/verify-docs.py b/scripts/verify-docs.py new file mode 100644 index 0000000..bcc3cda --- /dev/null +++ b/scripts/verify-docs.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Compare the published documentation with the files built for deployment.""" + +import argparse +import hashlib +import json +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + + +def manifest(site, versions): + paths = ["versions.html", "index.html"] + for version in versions: + if not version: + continue + if not version.startswith("v") or "/" in version or "\\" in version: + raise ValueError(f"Invalid version directory: {version!r}") + paths.append(f"{version}/index.html") + return { + path: hashlib.sha256((site / path).read_bytes()).hexdigest() + for path in dict.fromkeys(paths) + } + + +def verify(url, checks, timeout=300, retry_delay=10): + if not checks: + raise ValueError("Expected content checks must not be empty") + deadline = time.monotonic() + timeout + while True: + try: + for path, expected in checks.items(): + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("Verification deadline reached") + target = ( + f"{url.rstrip('/')}/{urllib.parse.quote(path)}" + f"?verify={time.time_ns()}" + ) + request = urllib.request.Request(target, headers={ + "Cache-Control": "no-cache", + "User-Agent": "Apollo-OpenAPI-deployment-verification", + }) + with urllib.request.urlopen(request, timeout=min(20, remaining)) as response: + actual = hashlib.sha256(response.read()).hexdigest() + if actual != expected: + raise ValueError(f"{path}: expected SHA-256 {expected}, got {actual}") + print(f"Verified {len(checks)} published files at {url}", flush=True) + return + except (OSError, ValueError) as error: + if isinstance(error, urllib.error.HTTPError): + error.close() + remaining = deadline - time.monotonic() + if remaining <= 0: + raise RuntimeError(f"Published documentation did not match: {error}") from error + print(f"Waiting for published content: {error}", flush=True) + time.sleep(min(retry_delay, remaining)) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + record = commands.add_parser("manifest") + record.add_argument("site", type=Path) + record.add_argument("--version", action="append", default=[]) + check = commands.add_parser("verify") + check.add_argument("--url", required=True) + check.add_argument("--checks", required=True) + check.add_argument("--timeout", type=float, default=300) + args = parser.parse_args() + try: + if args.command == "manifest": + print("checks=" + json.dumps(manifest(args.site, args.version), separators=(",", ":"))) + else: + verify(args.url, json.loads(args.checks), timeout=args.timeout) + except (OSError, ValueError, RuntimeError) as error: + print(str(error), file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_docs_deployment.py b/tests/test_docs_deployment.py new file mode 100644 index 0000000..bf4168b --- /dev/null +++ b/tests/test_docs_deployment.py @@ -0,0 +1,98 @@ +import contextlib +import hashlib +import http.server +import importlib.util +import io +import tempfile +import threading +import unittest +from pathlib import Path +from urllib.parse import urlsplit + + +SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "verify-docs.py" +SPEC = importlib.util.spec_from_file_location("verify_docs", SCRIPT) +DOCS = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(DOCS) + + +class DocsDeploymentTest(unittest.TestCase): + + @contextlib.contextmanager + def server(self, response): + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + status, body = response(urlsplit(self.path).path) + self.send_response(status) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.01}) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}" + finally: + server.shutdown() + server.server_close() + thread.join() + + def check(self, url, files, timeout=0.2): + hashes = {path: hashlib.sha256(body).hexdigest() for path, body in files.items()} + with contextlib.redirect_stdout(io.StringIO()): + DOCS.verify(url, hashes, timeout=timeout, retry_delay=0.01) + + def test_matching_site_passes(self): + files = {"versions.html": b"v0.3.12", "index.html": b"latest", + "v0.3.12/index.html": b"latest"} + with self.server(lambda path: (200, files[path.lstrip("/")])) as url: + self.check(url, files) + + def test_stale_successful_http_response_fails(self): + with self.server(lambda path: (200, b"v0.3.11")) as url: + with self.assertRaisesRegex(RuntimeError, "did not match"): + self.check(url, {"versions.html": b"v0.3.12"}) + + def test_missing_release_page_fails(self): + def response(path): + if path == "/versions.html": + return 200, b"v0.3.12" + return 404, b"not found" + + with self.server(response) as url: + with self.assertRaises(RuntimeError): + self.check(url, {"versions.html": b"v0.3.12", "v0.3.12/index.html": b"release"}) + + def test_retries_until_new_content_is_served(self): + requests = [] + + def response(path): + requests.append(path) + return 200, b"old" if len(requests) == 1 else b"new" + + with self.server(response) as url: + self.check(url, {"versions.html": b"new"}) + self.assertGreaterEqual(len(requests), 2) + + def test_manifest_checks_latest_and_older_release_separately(self): + with tempfile.TemporaryDirectory() as directory: + site = Path(directory) + files = {"versions.html": b"versions", "index.html": b"latest", + "v0.4.0/index.html": b"latest", "v0.3.12/index.html": b"older"} + for path, content in files.items(): + target = site / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(content) + checks = DOCS.manifest(site, ["v0.4.0", "v0.3.12", ""]) + self.assertEqual(set(files), set(checks)) + self.assertEqual(checks["index.html"], checks["v0.4.0/index.html"]) + self.assertNotEqual(checks["index.html"], checks["v0.3.12/index.html"]) + with self.assertRaises(FileNotFoundError): + DOCS.manifest(site, ["v0.5.0"]) + + +if __name__ == "__main__": + unittest.main() From 5d5f72f30d48ec54d18999e859cb5f899c64004e Mon Sep 17 00:00:00 2001 From: Jason Song Date: Tue, 22 Sep 2026 10:34:05 +0800 Subject: [PATCH 2/3] fix: verify next docs and retry incomplete HTTP responses --- README.md | 4 ++-- scripts/verify-docs.py | 16 +++++++++++----- tests/test_docs_deployment.py | 36 ++++++++++++++++++++++++++++++++++- 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 9d39ab2..aa5e34e 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,8 @@ See [all versions](https://openapi.apolloconfig.com/versions.html). Documentation changes on `main` deploy automatically. A `v*` tag push starts the deployment workflow on `main` using `workflow_dispatch`, avoiding the [Pages tag deployment issue](https://github.com/actions/deploy-pages/issues/383). -The deployment verifies the published version index, homepage, latest version, -and the triggering release version against the generated files. To retry a +The deployment verifies the published version index, homepage, `next`, latest +version, and the triggering release version against the generated files. To retry a release deployment, run **Deploy API Docs** on `main` with `expected_tag` set to the release tag. diff --git a/scripts/verify-docs.py b/scripts/verify-docs.py index bcc3cda..b579664 100644 --- a/scripts/verify-docs.py +++ b/scripts/verify-docs.py @@ -2,7 +2,9 @@ """Compare the published documentation with the files built for deployment.""" import argparse +import gzip import hashlib +import http.client import json import sys import time @@ -13,7 +15,7 @@ def manifest(site, versions): - paths = ["versions.html", "index.html"] + paths = ["versions.html", "index.html", "next/index.html"] for version in versions: if not version: continue @@ -41,22 +43,26 @@ def verify(url, checks, timeout=300, retry_delay=10): f"?verify={time.time_ns()}" ) request = urllib.request.Request(target, headers={ + "Accept-Encoding": "gzip", "Cache-Control": "no-cache", "User-Agent": "Apollo-OpenAPI-deployment-verification", }) with urllib.request.urlopen(request, timeout=min(20, remaining)) as response: - actual = hashlib.sha256(response.read()).hexdigest() + content = response.read() + if response.headers.get("Content-Encoding", "").lower() == "gzip": + content = gzip.decompress(content) + actual = hashlib.sha256(content).hexdigest() if actual != expected: raise ValueError(f"{path}: expected SHA-256 {expected}, got {actual}") print(f"Verified {len(checks)} published files at {url}", flush=True) return - except (OSError, ValueError) as error: + except (OSError, ValueError, http.client.HTTPException, EOFError) as error: if isinstance(error, urllib.error.HTTPError): error.close() remaining = deadline - time.monotonic() if remaining <= 0: - raise RuntimeError(f"Published documentation did not match: {error}") from error - print(f"Waiting for published content: {error}", flush=True) + raise RuntimeError(f"Published documentation did not match ({path}): {error}") from error + print(f"Waiting for published content ({path}): {error}", flush=True) time.sleep(min(retry_delay, remaining)) diff --git a/tests/test_docs_deployment.py b/tests/test_docs_deployment.py index bf4168b..42a35d2 100644 --- a/tests/test_docs_deployment.py +++ b/tests/test_docs_deployment.py @@ -1,4 +1,5 @@ import contextlib +import gzip import hashlib import http.server import importlib.util @@ -22,8 +23,11 @@ class DocsDeploymentTest(unittest.TestCase): def server(self, response): class Handler(http.server.BaseHTTPRequestHandler): def do_GET(self): - status, body = response(urlsplit(self.path).path) + result = response(urlsplit(self.path).path) + status, body = result[:2] self.send_response(status) + for key, value in (result[2] if len(result) > 2 else {}).items(): + self.send_header(key, value) self.end_headers() self.wfile.write(body) @@ -56,6 +60,22 @@ def test_stale_successful_http_response_fails(self): with self.assertRaisesRegex(RuntimeError, "did not match"): self.check(url, {"versions.html": b"v0.3.12"}) + def test_compressed_content_is_checked_after_decompression(self): + with self.server(lambda path: (200, gzip.compress(b"v0.3.12"), + {"Content-Encoding": "gzip"})) as url: + self.check(url, {"versions.html": b"v0.3.12"}) + + def test_stale_next_fails_when_release_content_is_current(self): + files = {"versions.html": b"versions", "index.html": b"release", + "next/index.html": b"new next"} + + def response(path): + return 200, b"old next" if path == "/next/index.html" else files[path.lstrip("/")] + + with self.server(response) as url: + with self.assertRaises(RuntimeError): + self.check(url, files) + def test_missing_release_page_fails(self): def response(path): if path == "/versions.html": @@ -77,10 +97,24 @@ def response(path): self.check(url, {"versions.html": b"new"}) self.assertGreaterEqual(len(requests), 2) + def test_retries_truncated_http_response(self): + requests = [] + + def response(path): + requests.append(path) + if len(requests) == 1: + return 200, b"partial", {"Content-Length": "100"} + return 200, b"complete" + + with self.server(response) as url: + self.check(url, {"index.html": b"complete"}) + self.assertGreaterEqual(len(requests), 2) + def test_manifest_checks_latest_and_older_release_separately(self): with tempfile.TemporaryDirectory() as directory: site = Path(directory) files = {"versions.html": b"versions", "index.html": b"latest", + "next/index.html": b"unreleased", "v0.4.0/index.html": b"latest", "v0.3.12/index.html": b"older"} for path, content in files.items(): target = site / path From 8165ffd573898066473b1e1b47cf74096e5ba4e7 Mon Sep 17 00:00:00 2001 From: Jason Song Date: Tue, 22 Sep 2026 11:38:39 +0800 Subject: [PATCH 3/3] refactor: simplify docs deployment fix --- .github/workflows/deploy-docs.yml | 43 +-------- .github/workflows/dispatch-docs.yml | 4 +- README.md | 5 +- scripts/build-docs.sh | 4 - scripts/verify-docs.py | 92 ------------------- tests/test_docs_deployment.py | 132 ---------------------------- 6 files changed, 6 insertions(+), 274 deletions(-) delete mode 100644 scripts/verify-docs.py delete mode 100644 tests/test_docs_deployment.py diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 3920623..944980a 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -6,22 +6,15 @@ on: - apollo-openapi.yaml - redocly.yaml - scripts/build-docs.sh - - scripts/verify-docs.py - package.json - package-lock.json - .github/workflows/deploy-docs.yml - workflow_dispatch: - inputs: - expected_tag: - description: Release tag that must be present in the published site - required: false - type: string + workflow_dispatch: {} permissions: contents: read - -env: - DOCS_URL: https://openapi.apolloconfig.com + pages: write + id-token: write concurrency: group: pages @@ -31,9 +24,6 @@ jobs: build: if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest - outputs: - source_sha: ${{ steps.expected.outputs.source_sha }} - checks: ${{ steps.expected.outputs.checks }} steps: # Build next from main and released versions from their tags. - uses: actions/checkout@v4 @@ -45,17 +35,7 @@ jobs: with: node-version: "20" - run: npm ci - - id: docs - run: ./scripts/build-docs.sh - - name: Record expected published content - id: expected - env: - LATEST_TAG: ${{ steps.docs.outputs.latest_tag }} - EXPECTED_TAG: ${{ inputs.expected_tag }} - run: | - echo "source_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - python3 scripts/verify-docs.py manifest site \ - --version "$LATEST_TAG" --version "$EXPECTED_TAG" >> "$GITHUB_OUTPUT" + - run: ./scripts/build-docs.sh - uses: actions/upload-pages-artifact@v3 with: path: site @@ -63,24 +43,9 @@ jobs: deploy: needs: build runs-on: ubuntu-latest - timeout-minutes: 15 - permissions: - contents: read - pages: write - id-token: write environment: name: github-pages url: https://openapi.apolloconfig.com steps: - - uses: actions/checkout@v4 - with: - ref: ${{ needs.build.outputs.source_sha }} - persist-credentials: false - id: deployment uses: actions/deploy-pages@v4 - - name: Verify published content - env: - EXPECTED_CHECKS: ${{ needs.build.outputs.checks }} - run: | - python3 scripts/verify-docs.py verify \ - --url "$DOCS_URL" --checks "$EXPECTED_CHECKS" diff --git a/.github/workflows/dispatch-docs.yml b/.github/workflows/dispatch-docs.yml index 4c38b47..0d12977 100644 --- a/.github/workflows/dispatch-docs.yml +++ b/.github/workflows/dispatch-docs.yml @@ -16,8 +16,6 @@ jobs: - name: Start a deployment from main env: GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ github.ref_name }} run: | gh workflow run deploy-docs.yml \ - --repo "$GITHUB_REPOSITORY" --ref main \ - --raw-field "expected_tag=$RELEASE_TAG" + --repo "$GITHUB_REPOSITORY" --ref main diff --git a/README.md b/README.md index aa5e34e..78b5ea5 100644 --- a/README.md +++ b/README.md @@ -18,10 +18,7 @@ See [all versions](https://openapi.apolloconfig.com/versions.html). Documentation changes on `main` deploy automatically. A `v*` tag push starts the deployment workflow on `main` using `workflow_dispatch`, avoiding the [Pages tag deployment issue](https://github.com/actions/deploy-pages/issues/383). -The deployment verifies the published version index, homepage, `next`, latest -version, and the triggering release version against the generated files. To retry a -release deployment, run **Deploy API Docs** on `main` with `expected_tag` set -to the release tag. +To retry a deployment, run **Deploy API Docs** manually on `main`. Generated code is treated as a temporary verification artifact, not as maintained source code or an official Apollo SDK. Apollo Portal pins a released diff --git a/scripts/build-docs.sh b/scripts/build-docs.sh index 676bdc6..50e05e1 100755 --- a/scripts/build-docs.sh +++ b/scripts/build-docs.sh @@ -111,8 +111,4 @@ echo "Generating versions.html..." echo '' } > "$SITE_DIR/versions.html" -if [ -n "${GITHUB_OUTPUT:-}" ]; then - echo "latest_tag=$LATEST_TAG" >> "$GITHUB_OUTPUT" -fi - echo "Done. Built $((${#BUILT_TAGS[@]} + 1)) versions (${#BUILT_TAGS[@]} tags + next)." diff --git a/scripts/verify-docs.py b/scripts/verify-docs.py deleted file mode 100644 index b579664..0000000 --- a/scripts/verify-docs.py +++ /dev/null @@ -1,92 +0,0 @@ -#!/usr/bin/env python3 -"""Compare the published documentation with the files built for deployment.""" - -import argparse -import gzip -import hashlib -import http.client -import json -import sys -import time -import urllib.error -import urllib.parse -import urllib.request -from pathlib import Path - - -def manifest(site, versions): - paths = ["versions.html", "index.html", "next/index.html"] - for version in versions: - if not version: - continue - if not version.startswith("v") or "/" in version or "\\" in version: - raise ValueError(f"Invalid version directory: {version!r}") - paths.append(f"{version}/index.html") - return { - path: hashlib.sha256((site / path).read_bytes()).hexdigest() - for path in dict.fromkeys(paths) - } - - -def verify(url, checks, timeout=300, retry_delay=10): - if not checks: - raise ValueError("Expected content checks must not be empty") - deadline = time.monotonic() + timeout - while True: - try: - for path, expected in checks.items(): - remaining = deadline - time.monotonic() - if remaining <= 0: - raise TimeoutError("Verification deadline reached") - target = ( - f"{url.rstrip('/')}/{urllib.parse.quote(path)}" - f"?verify={time.time_ns()}" - ) - request = urllib.request.Request(target, headers={ - "Accept-Encoding": "gzip", - "Cache-Control": "no-cache", - "User-Agent": "Apollo-OpenAPI-deployment-verification", - }) - with urllib.request.urlopen(request, timeout=min(20, remaining)) as response: - content = response.read() - if response.headers.get("Content-Encoding", "").lower() == "gzip": - content = gzip.decompress(content) - actual = hashlib.sha256(content).hexdigest() - if actual != expected: - raise ValueError(f"{path}: expected SHA-256 {expected}, got {actual}") - print(f"Verified {len(checks)} published files at {url}", flush=True) - return - except (OSError, ValueError, http.client.HTTPException, EOFError) as error: - if isinstance(error, urllib.error.HTTPError): - error.close() - remaining = deadline - time.monotonic() - if remaining <= 0: - raise RuntimeError(f"Published documentation did not match ({path}): {error}") from error - print(f"Waiting for published content ({path}): {error}", flush=True) - time.sleep(min(retry_delay, remaining)) - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - commands = parser.add_subparsers(dest="command", required=True) - record = commands.add_parser("manifest") - record.add_argument("site", type=Path) - record.add_argument("--version", action="append", default=[]) - check = commands.add_parser("verify") - check.add_argument("--url", required=True) - check.add_argument("--checks", required=True) - check.add_argument("--timeout", type=float, default=300) - args = parser.parse_args() - try: - if args.command == "manifest": - print("checks=" + json.dumps(manifest(args.site, args.version), separators=(",", ":"))) - else: - verify(args.url, json.loads(args.checks), timeout=args.timeout) - except (OSError, ValueError, RuntimeError) as error: - print(str(error), file=sys.stderr) - return 1 - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/tests/test_docs_deployment.py b/tests/test_docs_deployment.py deleted file mode 100644 index 42a35d2..0000000 --- a/tests/test_docs_deployment.py +++ /dev/null @@ -1,132 +0,0 @@ -import contextlib -import gzip -import hashlib -import http.server -import importlib.util -import io -import tempfile -import threading -import unittest -from pathlib import Path -from urllib.parse import urlsplit - - -SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "verify-docs.py" -SPEC = importlib.util.spec_from_file_location("verify_docs", SCRIPT) -DOCS = importlib.util.module_from_spec(SPEC) -SPEC.loader.exec_module(DOCS) - - -class DocsDeploymentTest(unittest.TestCase): - - @contextlib.contextmanager - def server(self, response): - class Handler(http.server.BaseHTTPRequestHandler): - def do_GET(self): - result = response(urlsplit(self.path).path) - status, body = result[:2] - self.send_response(status) - for key, value in (result[2] if len(result) > 2 else {}).items(): - self.send_header(key, value) - self.end_headers() - self.wfile.write(body) - - def log_message(self, *args): - pass - - server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) - thread = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.01}) - thread.start() - try: - yield f"http://127.0.0.1:{server.server_port}" - finally: - server.shutdown() - server.server_close() - thread.join() - - def check(self, url, files, timeout=0.2): - hashes = {path: hashlib.sha256(body).hexdigest() for path, body in files.items()} - with contextlib.redirect_stdout(io.StringIO()): - DOCS.verify(url, hashes, timeout=timeout, retry_delay=0.01) - - def test_matching_site_passes(self): - files = {"versions.html": b"v0.3.12", "index.html": b"latest", - "v0.3.12/index.html": b"latest"} - with self.server(lambda path: (200, files[path.lstrip("/")])) as url: - self.check(url, files) - - def test_stale_successful_http_response_fails(self): - with self.server(lambda path: (200, b"v0.3.11")) as url: - with self.assertRaisesRegex(RuntimeError, "did not match"): - self.check(url, {"versions.html": b"v0.3.12"}) - - def test_compressed_content_is_checked_after_decompression(self): - with self.server(lambda path: (200, gzip.compress(b"v0.3.12"), - {"Content-Encoding": "gzip"})) as url: - self.check(url, {"versions.html": b"v0.3.12"}) - - def test_stale_next_fails_when_release_content_is_current(self): - files = {"versions.html": b"versions", "index.html": b"release", - "next/index.html": b"new next"} - - def response(path): - return 200, b"old next" if path == "/next/index.html" else files[path.lstrip("/")] - - with self.server(response) as url: - with self.assertRaises(RuntimeError): - self.check(url, files) - - def test_missing_release_page_fails(self): - def response(path): - if path == "/versions.html": - return 200, b"v0.3.12" - return 404, b"not found" - - with self.server(response) as url: - with self.assertRaises(RuntimeError): - self.check(url, {"versions.html": b"v0.3.12", "v0.3.12/index.html": b"release"}) - - def test_retries_until_new_content_is_served(self): - requests = [] - - def response(path): - requests.append(path) - return 200, b"old" if len(requests) == 1 else b"new" - - with self.server(response) as url: - self.check(url, {"versions.html": b"new"}) - self.assertGreaterEqual(len(requests), 2) - - def test_retries_truncated_http_response(self): - requests = [] - - def response(path): - requests.append(path) - if len(requests) == 1: - return 200, b"partial", {"Content-Length": "100"} - return 200, b"complete" - - with self.server(response) as url: - self.check(url, {"index.html": b"complete"}) - self.assertGreaterEqual(len(requests), 2) - - def test_manifest_checks_latest_and_older_release_separately(self): - with tempfile.TemporaryDirectory() as directory: - site = Path(directory) - files = {"versions.html": b"versions", "index.html": b"latest", - "next/index.html": b"unreleased", - "v0.4.0/index.html": b"latest", "v0.3.12/index.html": b"older"} - for path, content in files.items(): - target = site / path - target.parent.mkdir(parents=True, exist_ok=True) - target.write_bytes(content) - checks = DOCS.manifest(site, ["v0.4.0", "v0.3.12", ""]) - self.assertEqual(set(files), set(checks)) - self.assertEqual(checks["index.html"], checks["v0.4.0/index.html"]) - self.assertNotEqual(checks["index.html"], checks["v0.3.12/index.html"]) - with self.assertRaises(FileNotFoundError): - DOCS.manifest(site, ["v0.5.0"]) - - -if __name__ == "__main__": - unittest.main()