Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -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 `@<sha> # 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
109 changes: 109 additions & 0 deletions tests/test_published_site.py
Original file line number Diff line number Diff line change
@@ -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
Loading