From 4ad0a1890854870b896f0c0331a9d443dddfdbfe Mon Sep 17 00:00:00 2001 From: "Chelsea Kelly-Reif (she/her)" Date: Fri, 7 Aug 2026 23:28:06 -0700 Subject: [PATCH] ci: publish site/ to GitHub Pages, and gate the bytes that get served site/ is tracked and is built from CAL FIRE's acquired files, which are not in git and never in CI, so this workflow cannot regenerate site/ and diff it. It checks what can be checked without those files, and says plainly what it cannot. The build job builds from the committed fixtures twice and requires the two runs to be byte-identical, then runs the new tests/test_published_site.py over the committed site/. Those tests require the published artifacts to be a measurement of the real files rather than fixture output, and require the provenance they publish to still equal the reviewed constants in src/perimeter/sources.py, so an acquisition that bumps a version, a byte count or a hash without a rebuild fails instead of being published. They also require every link to be relative, because the site is served from /perimeter/ and an href rooted at / lands outside it, a break that is invisible when the pages are opened from a directory. html-validate and axe-core then run over site/ itself, so what gets served is what passed the WCAG gate rather than a fixture build that resembles it. Top-level permissions are empty, each job takes only what it needs, and every action is pinned to the commit SHA of its tag. Pages still has to be pointed at GitHub Actions in the repository settings before the first deploy can succeed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011sjkAHcKuBJoxvzc7D6XMA --- .github/workflows/pages.yml | 102 ++++++++++++++++++++++++++++++++ tests/test_published_site.py | 109 +++++++++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 .github/workflows/pages.yml create mode 100644 tests/test_published_site.py diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..910ec1c --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,102 @@ +# Publish site/ to GitHub Pages, after checking the bytes that are about to be published +# rather than a rebuild of them. +# +# site/ is tracked in git and is built from CAL FIRE's acquired files, which are not in +# git and never in CI. So this workflow cannot regenerate site/ and diff it, the way a +# repository whose site renders from committed inputs would. What it does instead: +# +# 1. Builds from the committed fixtures twice and requires the two runs to be +# byte-identical, which is the determinism claim the README makes, checked here as +# well as in ci.yml. +# 2. Runs tests/test_published_site.py over the committed site/, which requires the +# published artifacts to be a measurement of the real files rather than fixture +# output, and requires the provenance they publish to still match the reviewed +# constants in src/perimeter/sources.py. An acquisition that changes a version, a +# byte count or a hash without a rebuild of site/ fails here. +# 3. Runs html-validate and axe-core over site/ itself, so the pages that get served +# are the pages the WCAG gate passed, not a fixture build that resembles them. +# +# What none of that proves is that site/ is what the current pipeline would produce from +# CAL FIRE's files; only a machine holding those files can settle that, with `make site` +# and no diff. The tests say so in as many words rather than implying more. +# +# No network for data. Every input read here is committed. Every action is pinned to the +# full 40-char commit SHA of its tag, resolved 2026-08-07 via +# `gh api repos/OWNER/REPO/git/matching-refs/tags`, and kept as `@ # vX.Y.Z` so the +# version stays readable next to the pin. +# +# Before the first deploy can succeed, Pages has to be told to take its source from this +# workflow: Settings > Pages > Build and deployment > Source > GitHub Actions. +name: publish-site + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: {} + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + + - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 + with: + version: ">=0.11.0" + python-version: "3.12" + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "22" + cache: npm + + - run: make sync + + - name: the offline build must produce byte-identical output twice + run: | + make site-offline + find build/site-offline -type f | sort | xargs shasum -a 256 > /tmp/first.txt + make site-offline + find build/site-offline -type f | sort | xargs shasum -a 256 > /tmp/second.txt + diff /tmp/first.txt /tmp/second.txt + + - name: the committed site/ must be a current measurement of the real files + run: uv run pytest tests/test_published_site.py -q + + - run: make node-sync + + - name: the pages being published must pass the same HTML and WCAG gates + run: | + npx html-validate "site/*.html" + node tools/a11y.mjs site + + - uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0 + + - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1 + with: + path: site + + deploy: + needs: build + runs-on: ubuntu-latest + # The two scopes the Pages deployment API needs, on the one job that needs them. The + # build job, which is the job that runs repository code, holds neither. + permissions: + pages: write # create the Pages deployment + id-token: write # mint the OIDC token that deployment is authenticated with + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 diff --git a/tests/test_published_site.py b/tests/test_published_site.py new file mode 100644 index 0000000..3ab5586 --- /dev/null +++ b/tests/test_published_site.py @@ -0,0 +1,109 @@ +"""The committed ``site/`` is the thing that gets published, so check that, not a rebuild. + +Every other page check in this repository runs over pages built from the committed +fixtures into a temporary directory. That is the right place to check the renderer, and +it says nothing at all about ``site/``, which is the directory the Pages workflow +uploads. ``site/`` is built from CAL FIRE's acquired files, and those are not in git and +never in CI, so a full rebuild cannot be repeated here to prove the committed bytes are +current. What can be checked without them is checked here: + +* The five files the workflow publishes are present and not empty. +* They are a measurement of the real files, not fixture output wearing its clothes. +* The provenance they publish is still the reviewed provenance in + :mod:`perimeter.sources`. A retrieval that bumps a version, a byte count or a hash + without rebuilding ``site/`` fails here instead of being published. +* Every link is relative. The site is served from a subpath, + ``chelseakr.github.io/perimeter/``, where an href rooted at ``/`` lands outside the + site and 404s. Nothing about that failure is visible locally, where the pages are + opened from a directory. +* The unofficial framing is on every page that gets served. + +What this cannot claim: it does not prove ``site/`` is what the current pipeline would +produce from CAL FIRE's files. Only a machine holding those files can settle that, by +running ``make site`` and finding no diff. +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any + +import pytest + +from perimeter.artifacts import _source_json +from perimeter.sources import DINS, FRAP, Source + +SITE = Path(__file__).resolve().parents[1] / "site" +PAGES = ("index.html", "perimeters.html", "dins.html") +ARTIFACTS = (("perimeters-coverage.json", FRAP), ("dins-coverage.json", DINS)) +MEASUREMENTS = (("perimeters.html", FRAP), ("dins.html", DINS)) + +ROOTED = re.compile(r"""(?:href|src)\s*=\s*["']/[^"']*["']""") +"""A link rooted at the server, which is the one shape a subpath deploy breaks.""" + + +def artifact(name: str) -> dict[str, Any]: + payload: dict[str, Any] = json.loads( + (SITE / "data" / name).read_text(encoding="utf-8") + ) + return payload + + +@pytest.mark.parametrize( + "name", [*PAGES, "data/perimeters-coverage.json", "data/dins-coverage.json"] +) +def test_the_published_site_holds_every_file_the_workflow_uploads(name: str) -> None: + path = SITE / name + assert path.is_file(), f"{name} is missing from site/" + assert path.stat().st_size > 0, f"{name} is empty" + + +@pytest.mark.parametrize("name", [name for name, _ in ARTIFACTS]) +def test_the_published_artifacts_are_not_fixture_output(name: str) -> None: + """Fixture output must never be able to pass itself off as a measurement.""" + assert artifact(name)["is_fixture"] is False + + +@pytest.mark.parametrize(("name", "source"), ARTIFACTS) +def test_the_published_artifacts_carry_the_reviewed_provenance( + name: str, source: Source +) -> None: + """The published provenance must still be the reviewed provenance. + + This is the staleness check the acquired files are not needed for. If a new + acquisition changes a version, a record count, a byte count, a hash or a quoted + caveat in :mod:`perimeter.sources`, and ``site/`` is not rebuilt from the new files, + the published block stops matching and this fails. + """ + assert artifact(name)["source"] == _source_json(source, is_fixture=False) + + +@pytest.mark.parametrize(("name", "source"), MEASUREMENTS) +def test_a_published_measurement_page_names_its_source_and_when_it_was_read( + name: str, source: Source +) -> None: + text = (SITE / name).read_text(encoding="utf-8") + assert source.retrieved in text, ( + f"{name} does not state the reviewed retrieval date" + ) + assert source.version in text, f"{name} does not state the reviewed source version" + assert "not applicable to a fixture build" not in text, f"{name} is fixture output" + + +@pytest.mark.parametrize("name", PAGES) +def test_no_published_link_is_rooted_at_the_server(name: str) -> None: + """Served from /perimeter/, an href rooted at / leaves the site. None may be.""" + found = ROOTED.findall((SITE / name).read_text(encoding="utf-8")) + assert not found, ( + f"{name} links to an absolute path, which breaks under a subpath: {found}" + ) + + +@pytest.mark.parametrize("name", PAGES) +def test_every_published_page_says_it_is_not_affiliated_with_cal_fire( + name: str, +) -> None: + text = (SITE / name).read_text(encoding="utf-8") + assert "Not affiliated with or endorsed by CAL FIRE" in text