Files
tiennm99 d8b5b66fcf feat: keep the downloaded database, and stop rebuilding it every deploy
Two caches, one on each side.

In the browser, the response is stored in Cache Storage keyed by the
server's ETag, so the transfer is paid once per device rather than once
per visit. A stored copy opens without the gate: consent was given the
first time and reuse costs no network. A redeploy changes the ETag, so
the new version replaces the old instead of answering with last week's
data, and every older version of that database is dropped so one dataset
never occupies the disk twice. When the server cannot be reached at all,
any stored version is used, which incidentally makes the site work
offline. Storing is best effort — a full disk means downloading again
next time, which is no reason to fail the page.

The response is cached from a clone while the original is read for the
progress bar, rather than buffering the file a second time in the one
place where memory is already the binding constraint.

In CI, the built databases are cached on the inputs that determine them:
data/**, parser/** and datasets.json. Parsing 348 MB of spreadsheets is
the slow part of the job, and a web or docs change cannot alter a
database, so those pushes restore instead of rebuilding. Exact matches
only — no restore-keys, since a near-miss would publish databases built
from inputs the commit does not describe.
2026-08-14 17:52:09 +07:00

171 lines
6.7 KiB
YAML

name: Deploy to GitHub Pages
on:
push:
branches: [main]
# Pull requests run the build job only: the deploy job is guarded to main, so
# a branch can be verified end to end without touching the live site.
pull_request:
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
# Keyed by ref, not just "pages". With one shared group a pull-request run and
# a main deploy compete for the same lane, and cancel-in-progress means the
# newer one wins: the deploy of #10 was killed 3m22s in by a PR run that
# started after it, and the site silently stayed on the previous build while
# every check stayed green. Per ref, a push still cancels its own superseded
# run, which is the case where cancelling is worth having.
concurrency:
group: pages-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
env:
# Every Go module here is cgo-free — grate, excelize, yaml.v3, x/net,
# x/text and modernc.org/sqlite — so no C toolchain is needed. Set
# explicitly rather than relying on the default.
CGO_ENABLED: '0'
steps:
- uses: actions/checkout@v7
# The version comes from go.mod rather than a literal here. setup-go sets
# GOTOOLCHAIN=local, so Go will not fetch the toolchain a module asks for
# — the one installed has to satisfy it. A literal '1.26' resolves to
# whatever patch the runner manifest has, which was 1.26.5 against a
# go.mod requiring 1.26.6, and the build stopped there. All three modules
# are bumped together, so parser/go.mod speaks for them.
- uses: actions/setup-go@v7
with:
go-version-file: parser/go.mod
cache-dependency-path: |
parser/go.sum
crawler/go.sum
assembler/go.sum
# web/ is the only npm project in the repository; the other stages are Go.
- uses: actions/setup-node@v7
with:
node-version: '24'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Install web dependencies
working-directory: web
run: npm ci
# The reader-fidelity suite compares every real input file against a
# committed hash oracle, so it is the regression guard for the whole
# reader. Runs before anything is built.
- name: Test parser
run: go -C parser test ./...
# The crawler is not part of the build — it only refreshes data/ by hand.
# It is still tested here so it cannot rot unnoticed, and because its
# fixture test guards the parser: input filenames decide which row
# survives a duplicate exam number.
- name: Test crawler
run: go -C crawler test ./...
# The assembler owns every guard between a built database and the
# published site, so its tests are the ones that prove a short or missing
# database cannot ship.
- name: Test assembler
run: go -C assembler test ./...
# The tests cover the framework-free modules, including the ASCII fold
# that has to match the Go parser.
- name: Test web
working-directory: web
run: npm test
- name: Lint web
working-directory: web
run: npm run lint
# excelize carries an open advisory, and the 2017 refresh runbook feeds
# network-downloaded spreadsheets straight into the parser.
- name: Vulnerability scan
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
GOVULNCHECK="$(go env GOPATH)/bin/govulncheck"
for m in parser crawler assembler; do (cd "$m" && "$GOVULNCHECK" ./...); done
# Parsing 348 MB of spreadsheets is the slowest part of the job, and its
# output is a pure function of the inputs in the key: the source files,
# the parser (schema, transforms, configs) and the registry the row-count
# and size guards read. Anything else — web code, docs, workflow — cannot
# change a database, so those pushes restore one instead of rebuilding.
#
# Exact matches only, with no restore-keys: a near-miss here would mean
# publishing a database built from different inputs than the commit says.
- name: Cache the built databases
id: db-cache
uses: actions/cache@v4
with:
path: .build/public/db
key: db-${{ hashFiles('data/**', 'parser/**', 'datasets.json') }}
# The whole pipeline: compile the parser, build and verify each database
# against its registry row count and size, build the web app, and
# assemble _site — refusing to continue if a database is short, an
# artifact looks truncated, or one is missing entirely. On a cache hit
# the database step is skipped; the artifact it would have produced was
# verified by the run that built and stored it.
- name: Build site
run: |
if [ "${{ steps.db-cache.outputs.cache-hit }}" = "true" ]; then
echo "Databases restored from cache; building the site only."
go -C assembler run ./cmd/assemble site
else
go -C assembler run ./cmd/assemble
fi
- uses: actions/upload-pages-artifact@v5
with:
path: _site
deploy:
# Deploy only from main. pull_request and workflow_dispatch both run on
# other branches, and publishing one would put that branch's output on the
# live site while concurrency cancel-in-progress killed an in-flight good
# deploy on the way.
if: github.ref == 'refs/heads/main'
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v5
- uses: actions/checkout@v7
# Checks the published file is a database, not an error page or a
# truncated upload, by reading its first bytes. A range request is used
# only because it is the cheapest way to see them without pulling 142 MB;
# the site itself downloads the file whole.
- name: Verify the published databases are readable
env:
PAGE_URL: ${{ steps.deployment.outputs.page_url }}
run: |
set -euo pipefail
for id in $(jq -r '.datasets[].id' datasets.json); do
url="${PAGE_URL%/}/db/${id}.sqlite3"
if ! magic=$(curl -sf -r 0-14 -H 'Accept-Encoding: identity;q=1, *;q=0' "$url"); then
echo "::error::$url is not fetchable"
exit 1
fi
if [ "$magic" != "SQLite format 3" ]; then
echo "::error::$url does not start with the SQLite header"
exit 1
fi
echo "$url: SQLite format 3"
done