Skip to content

ci: publish site/ to GitHub Pages, and gate the bytes that get served - #7

Merged
ChelseaKR merged 1 commit into
mainfrom
feat/publish-site-to-pages
Aug 8, 2026
Merged

ci: publish site/ to GitHub Pages, and gate the bytes that get served#7
ChelseaKR merged 1 commit into
mainfrom
feat/publish-site-to-pages

Conversation

@ChelseaKR

Copy link
Copy Markdown
Owner

Adds .github/workflows/pages.yml, which publishes the committed site/ to GitHub Pages on every push to main, plus tests/test_published_site.py, which is what makes that publication conditional on something.

The honest shape of the staleness check

The brief was to regenerate site/ in the workflow and fail if the regenerated output differs from what is committed. That is not available here, and the difference is worth stating rather than papering over: site/ is built from CAL FIRE's acquired files under data/raw/, which is gitignored and never in CI. A workflow with no network and no acquired files cannot reproduce it. make site-offline builds from the committed fixtures, which is a different measurement with is_fixture: true stamped on it, and publishing that would be exactly the substitution this project's design goes out of its way to prevent.

So the build job checks everything that can be checked without those files:

  1. Determinism. Builds from the committed fixtures twice and requires the two runs to be byte-identical. Same check ci.yml runs, now also standing between the repository and the web.
  2. The published artifacts are a current measurement. tests/test_published_site.py requires is_fixture: false in both artifacts, and requires each artifact's source block to equal what perimeter.artifacts._source_json computes from the reviewed constants in src/perimeter/sources.py. That is the realistic staleness failure caught: a new acquisition bumps a version, record count, byte count, hash or quoted caveat, and site/ is not rebuilt. It fails here.
  3. The pages name their own source and are not fixture output. Each measurement page must print its source's reviewed retrieval date and version, and must not carry the fixture wording.
  4. The published bytes pass the same gates a built page does. html-validate and node tools/a11y.mjs run over site/ itself, not over a fixture build that resembles it.

What is not claimed, and the test module says so in its own docstring: none of this proves site/ is what the current pipeline would produce from CAL FIRE's files. Only a machine holding those files can settle that. I settled it once, locally, on this branch: python -m perimeter.cli --perimeters data/raw/frap_perimeters.json --dins data/raw/dins_postfire.json into a scratch directory, then diff -r against site/, and the rebuild is byte-for-byte identical to what is committed.

The subpath check

The site is served from chelseakr.github.io/perimeter/, where an href rooted at / lands outside the site and 404s, and nothing about that is visible locally where the pages are opened from a directory. test_no_published_link_is_rooted_at_the_server makes that permanent.

Auditing the current pages found nothing to fix. Every internal link is a bare filename (index.html, perimeters.html, dins.html, #content), the only absolute URLs are external ones to data.cnra.ca.gov and data.ca.gov, there are no src attributes at all, and the CSS is inline. One note on the brief: the pages do not reference site/data/*.json, in prose or as links. Those two artifacts will publish at /perimeter/data/perimeters-coverage.json and /perimeter/data/dins-coverage.json and resolve fine, but nothing on the pages points a reader at them. Linking them would change rendered output, and rendered output can only be regenerated on a machine holding data/raw/, so I left it alone rather than committing HTML this branch cannot rebuild. Worth a follow-up on a branch where the files are in hand.

Supply chain and permissions

  • Top-level permissions: {}, scoped per job: contents: read on build, pages: write and id-token: write on deploy only. The job that runs repository code holds neither deploy scope.
  • concurrency: group: pages, cancel-in-progress: false.
  • Every action pinned to the full 40-char commit SHA of its tag, in the @<sha> # vX.Y.Z form ci.yml already uses. actions/checkout v4.4.0, astral-sh/setup-uv v5.4.2 and actions/setup-node v4.4.0 reuse the pins already in this repository; configure-pages v5.0.0, upload-pages-artifact v3.0.1 and deploy-pages v4.0.5 were resolved 2026-08-07 via gh api repos/OWNER/REPO/git/matching-refs/tags.
  • persist-credentials: false on checkout.
  • No network for data. Every input the build reads is committed.

Checks run before opening this

  • make verify passes: ruff, ruff format, mypy strict, 450 tests, 100% coverage against a 90% floor, pip-audit, npm audit, html-validate and axe-core.
  • The workflow's own steps run clean locally: the offline build is byte-identical across two runs, tests/test_published_site.py is 17 passed, and html-validate and axe-core are clean over site/ (3 pages, 6 rule sets).
  • The full-data rebuild diff described above.
  • zizmor on the new workflow: no findings at the default persona. At --persona=pedantic the only two remaining are informational anonymous-definition notes about unnamed jobs, which matches how ci.yml is already written.
  • The unofficial framing is on all three published pages, gated now by test_every_published_page_says_it_is_not_affiliated_with_cal_fire over the committed bytes, in addition to the existing check over built pages.

One settings step is still needed, and only Chelsea can do it

This PR cannot enable Pages, and nothing here was deployed. After merge:

Settings > Pages > Build and deployment > Source > GitHub Actions.

The first publish-site run after that lands the site at:

https://chelseakr.github.io/perimeter/

Repository visibility was not touched. It is public and stays public.

🤖 Generated with Claude Code

https://claude.ai/code/session_011sjkAHcKuBJoxvzc7D6XMA

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011sjkAHcKuBJoxvzc7D6XMA
@ChelseaKR
ChelseaKR merged commit 2cfaa92 into main Aug 8, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant