@@ -16,12 +16,12 @@ concurrency:
1616
1717# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
1818permissions :
19- contents : read # Read access to repository contents
19+ contents : write # Push the provenance ledger back to the branch (see the step that does)
2020 pages : write # Write access to GitHub Pages
2121 id-token : write # Write access to ID tokens
2222 issues : write # Write access to issues
2323 pull-requests : write # Write access to pull requests
24- actions : write # Write access to GitHub Actions
24+ actions : read # Read the previous run's artifact, for the referee revision diff
2525
2626env :
2727 REFEREE_REPO : LeanMachineLearning/exposition
3232 # authentication and never expire, while a cross-repository artifact download needs a PAT with
3333 # `actions:read` (a workflow's own GITHUB_TOKEN cannot reach another repository's artifacts) and
3434 # is deleted after 90 days.
35+ #
36+ # Must resolve to a release that has `collect --hashes` and the `provenance` subcommand, both of
37+ # which this workflow now passes. v0.1.3 is the first that does; anything earlier fails with
38+ # "Unknown or incomplete option". Empty resolves to the most recent release.
3539 REFEREE_VERSION : " "
3640 # Kept as `exposition` rather than renamed to `referee` along with the tool: this path is the
3741 # published URL, and existing links to it would break.
3842 REFEREE_SITE_URL : https://leanmachinelearning.org/LML/exposition
3943
44+ # The library that is exposed on the site, and the one excluded from every phase that imports the
45+ # project. Named once here because `collect`, `extract`, `highlight` and the hash export all have
46+ # to agree — a mismatch between the hashed set and the collected set makes `provenance` hard-fail.
47+ REFEREE_ROOT : LeanMachineLearning
48+ REFEREE_EXCLUDE_LIB : LMLTutorial
49+
50+ # Generated data, deliberately *outside* the working tree. `provenance` records whether the tree
51+ # was clean when it folded, via `git status --porcelain`, which counts untracked files — so
52+ # leaving these where they land would permanently stamp the ledger as built from a dirty tree and
53+ # make the site disclaim its own blame.
54+ REFEREE_DATA : /tmp/referee-data/referee-data.json
55+ REFEREE_HASHES : /tmp/referee-data/hashes.jsonl
56+
57+ # The provenance ledger, committed to *this* repository rather than kept as an artifact: it is
58+ # append-only and its whole value is that it remembers years of history, which a 90-day artifact
59+ # cannot. Each run folds the current commit into it, so its resolution is this workflow's cadence
60+ # — per commit, since this runs on every push to main.
61+ REFEREE_PROVENANCE : provenance.json
62+
63+ # `semantic_hash` supplies the rename-invariant hashes the ledger and the revision diff are keyed
64+ # on. Leave the ref empty for the default branch; pin a tag or branch once the ledger matters,
65+ # since a change in how it hashes reads as every declaration changing meaning at once.
66+ SEMANTIC_HASH_REPO : https://github.com/mathlib-initiative/semantic_hash
67+ SEMANTIC_HASH_REF : " "
68+
4069jobs :
4170 build_project :
4271 runs-on : ubuntu-latest
4372 name : Build project
4473 steps :
4574 - name : Cleanup to free disk space
46- uses : jlumbroso/free-disk-space@main
75+ uses : jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.0
4776 with :
4877 tool-cache : false
4978 android : true # saves approximately 12 GB
5483 swap-storage : false
5584
5685 - name : Checkout project
57- uses : actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
86+ uses : actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
5887 with :
88+ # Load-bearing for provenance, not just a convenience: the ledger's edit half is
89+ # `git blame`, and at the default `fetch-depth: 1` blame attributes the entire library to
90+ # the single fetched commit.
5991 fetch-depth : 0 # Fetch all history for all branches and tags
6092
6193 - name : Build and lint project
@@ -65,17 +97,13 @@ jobs:
6597 lint : true
6698 mk_all-check : true
6799
68- - name : Build Verso Documentation
69- run : |
70- ./scripts/build_docs.sh
71-
72100 - name : Download referee binary
73101 if : github.event_name == 'push'
74102 env :
75103 GH_TOKEN : ${{ secrets.GITHUB_TOKEN }}
76104 run : |
77105 set -euo pipefail
78- mkdir -p /tmp/referee
106+ mkdir -p /tmp/referee "$(dirname "$REFEREE_DATA")"
79107 gh release download ${REFEREE_VERSION:+"$REFEREE_VERSION"} \
80108 -R "$REFEREE_REPO" \
81109 --pattern 'referee-linux-x86_64-*.tar.gz' \
@@ -97,9 +125,98 @@ jobs:
97125 exit 1
98126 fi
99127
128+ # Built here rather than taken as a Lake dependency, for the same reason referee is downloaded
129+ # rather than built: it loads this project's environment and refuses to run unless its own
130+ # sysroot matches, so it has to be compiled against *this* toolchain, not the one it pins.
131+ # Overwriting the pin is the whole patch. No dependencies beyond core, so this is ~1-2 min and
132+ # the toolchain is already on the runner from the build step.
133+ - name : Build semantic_hash
134+ if : github.event_name == 'push'
135+ run : |
136+ set -euo pipefail
137+ git clone --depth 1 ${SEMANTIC_HASH_REF:+--branch "$SEMANTIC_HASH_REF"} \
138+ "$SEMANTIC_HASH_REPO" /tmp/semantic_hash
139+ cp lean-toolchain /tmp/semantic_hash/lean-toolchain
140+ cd /tmp/semantic_hash
141+ lake build semantic_hash
142+
143+ # No `lake env`: it reads this project's LEAN_PATH itself from `--dir`. `--imports` takes the
144+ # same roots `collect` exposes — `LeanMachineLearning` alone, which is also how `LMLTutorial`
145+ # stays out of the hashed set without an exclusion flag of its own. It hashes the whole
146+ # upstream cone, which is what makes the hashes deep enough to notice a Mathlib bump changing
147+ # a statement underneath us, and `collect` keeps only the exposed declarations' entries.
148+ - name : Export semantic hashes
149+ if : github.event_name == 'push'
150+ run : |
151+ set -euo pipefail
152+ /tmp/semantic_hash/.lake/build/bin/semantic_hash export \
153+ --dir . --imports "$REFEREE_ROOT" --output "$REFEREE_HASHES"
154+
155+ # The phase split: everything needing a Lean environment produces data, and everything after
156+ # is a pure function of that data. Collection is its own step because `provenance` has to run
157+ # between it and the rendering.
158+ - name : Collect referee data
159+ if : github.event_name == 'push'
160+ run : |
161+ set -euo pipefail
162+ lake env "$REFEREE_BIN" collect \
163+ --root "$REFEREE_ROOT" \
164+ --exclude-lib "$REFEREE_EXCLUDE_LIB" \
165+ --hashes "$REFEREE_HASHES" \
166+ --data "$REFEREE_DATA"
167+
168+ # Needs a git working tree and no Lean environment at all, so it is a phase of its own. Runs
169+ # before `extract`/`highlight`/`build_docs.sh`/docgen write into the tree, so that the
170+ # cleanliness it records is the repository's and not this job's leftovers.
171+ #
172+ # Hard-fails rather than degrading, by design: it refuses to run on data collected without
173+ # `--hashes`, because the ledger is append-only and a text-keyed one would record the mass
174+ # false change of a toolchain upgrade permanently. A failure here means the hash export missed
175+ # declarations `collect` exposes, which is worth stopping for.
176+ #
177+ # `--ref` is left off, so each revision is named by `git describe --tags --always` — a tag
178+ # where there is one, else a short sha. Pass `--ref` explicitly to fold at release cadence
179+ # instead ("changed between v0.2 and v0.3").
180+ - name : Fold this revision into the provenance ledger
181+ if : github.event_name == 'push'
182+ run : |
183+ set -euo pipefail
184+ # Dumped so that the tool's "working tree has uncommitted changes" warning, if it fires,
185+ # comes with the reason attached rather than sending someone hunting for it.
186+ echo "working tree at fold time:"; git status --porcelain
187+ "$REFEREE_BIN" provenance \
188+ --data "$REFEREE_DATA" \
189+ --provenance "$REFEREE_PROVENANCE"
190+
191+ # The ledger is only worth keeping if it survives the run that wrote it. Pushed with
192+ # GITHUB_TOKEN, which by design does not trigger another workflow run, so this cannot loop
193+ # back into itself.
194+ #
195+ # A rejected push is a warning and not a failure: the fold is idempotent per commit and the
196+ # next run folds whatever it finds, so losing a race costs one revision of resolution rather
197+ # than correctness — and it must not take the deployment down with it.
198+ - name : Commit the provenance ledger
199+ if : github.event_name == 'push'
200+ run : |
201+ set -euo pipefail
202+ if git diff --quiet -- "$REFEREE_PROVENANCE" &&
203+ [ -z "$(git ls-files --others --exclude-standard -- "$REFEREE_PROVENANCE")" ]; then
204+ echo "ledger unchanged (already folded at this commit); nothing to commit"
205+ exit 0
206+ fi
207+ git config user.name "github-actions[bot]"
208+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
209+ git add "$REFEREE_PROVENANCE"
210+ git commit -m "chore(referee): fold ${GITHUB_SHA:0:7} into the provenance ledger"
211+ git push origin "HEAD:${GITHUB_REF_NAME}" ||
212+ echo "::warning::could not push the provenance ledger; the next run will fold this \
213+ revision together with the following one"
214+
100215 # The previous run's collected data, so the site can say what changed since it. Optional in
101216 # every direction: the first run has nothing to download, a failed lookup is swallowed, and
102- # `build-site` simply omits the Changes page when no baseline reaches it.
217+ # `build-site` simply omits the Changes page when no baseline reaches it. Complementary to the
218+ # ledger rather than replaced by it — the ledger says *when* a declaration last changed, the
219+ # baseline shows the two statements side by side.
103220 - name : Fetch previous referee data for the revision diff
104221 if : github.event_name == 'push'
105222 continue-on-error : true
@@ -113,35 +230,54 @@ jobs:
113230 [ -n "$run_id" ] || { echo "no earlier successful run; skipping the baseline"; exit 0; }
114231 gh run download "$run_id" -R "$GITHUB_REPOSITORY" \
115232 -n referee-data -D /tmp/referee-baseline
116- echo "REFEREE_BASELINE=/tmp/referee-baseline/referee-data.json" >> "$GITHUB_ENV"
233+ # Located rather than assumed: the artifact's internal layout follows whatever path was
234+ # uploaded, and a wrong guess here would silently cost the Changes page on every run.
235+ found=$(find /tmp/referee-baseline -name '*.json' | head -1)
236+ [ -n "$found" ] || { echo "artifact holds no JSON; skipping the baseline"; exit 0; }
237+ echo "REFEREE_BASELINE=$found" >> "$GITHUB_ENV"
117238
118239 - name : Build the referee site
119240 if : github.event_name == 'push'
120241 run : |
121242 set -euo pipefail
122- # The phase split: everything needing a Lean environment produces data, and rendering is a
123- # pure function of that data. `--exclude-lib` applies to the three phases that import the
124- # project, and to none of `build-site`, which reads only the JSON. `highlight-extracted`
125- # is deliberately skipped — it re-elaborates every extracted file and costs more than the
243+ # A baseline written by an older `collect` is rejected outright — the data file carries a
244+ # format version and `build-site` treats a mismatch as fatal, not as something to degrade
245+ # around. Dropped here instead: one run without a Changes page, and the next has one
246+ # again, which beats failing the deployment over a stale artifact.
247+ baseline="${REFEREE_BASELINE:-}"
248+ if [ -n "$baseline" ] &&
249+ [ "$(jq -r .version "$baseline")" != "$(jq -r .version "$REFEREE_DATA")" ]; then
250+ echo "::notice::baseline is collected-data version $(jq -r .version "$baseline") but \
251+ this build writes $(jq -r .version "$REFEREE_DATA"); skipping the revision diff"
252+ baseline=""
253+ fi
254+
255+ # `--exclude-lib` goes only to `extract`, the one phase here that imports the project
256+ # wholesale. `highlight` fans out to per-module workers driven by the data file, and
257+ # `build-site` reads only the JSON, so neither needs it. `highlight-extracted` is
258+ # deliberately skipped — it re-elaborates every extracted file and costs more than the
126259 # rest of this job combined; without it the standalone files are still written and linked,
127260 # just not rendered inline.
128- lake env "$REFEREE_BIN" collect \
129- --root LeanMachineLearning --exclude-lib LMLTutorial --data referee-data.json
130261 lake env "$REFEREE_BIN" extract \
131- --data referee-data.json --exclude-lib LMLTutorial --output ./referee-site
262+ --data "$REFEREE_DATA" --exclude-lib "$REFEREE_EXCLUDE_LIB" --output ./referee-site
132263 lake env "$REFEREE_BIN" highlight \
133- --data referee-data.json --output ./referee-site
264+ --data "$REFEREE_DATA" --output ./referee-site
134265 # `--trust` is an editorial claim, not a derived fact: it says whoever publishes this site
135266 # vouches for Mathlib and everything under it. Drop it and every upstream package counts
136267 # as unaudited; name more packages to vouch for more.
268+ #
269+ # `--provenance` is read-only here: `build-site` never folds. It adds the "meaning
270+ # unchanged since" line, the Browse column, the revision selector on the Changes page, and
271+ # pins every source link to the commit the ledger was folded at instead of to `main`.
137272 "$REFEREE_BIN" build-site \
138- --data referee-data.json \
273+ --data "$REFEREE_DATA" \
139274 --output ./referee-site \
140275 --repo-url "https://github.com/$GITHUB_REPOSITORY" \
141276 --site-url "$REFEREE_SITE_URL" \
142277 --trust mathlib \
143- ${REFEREE_BASELINE:+--baseline "$REFEREE_BASELINE"} \
144- ${REFEREE_BASELINE:+--baseline-label "the previous build"}
278+ --provenance "$REFEREE_PROVENANCE" \
279+ ${baseline:+--baseline "$baseline"} \
280+ ${baseline:+--baseline-label "the previous build"}
145281
146282 # Kept as an artifact so the *next* run can diff against it. Also the thing to download when
147283 # you want to re-render the site locally without re-importing the project.
@@ -150,9 +286,16 @@ jobs:
150286 uses : actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
151287 with :
152288 name : referee-data
153- path : referee-data.json
289+ path : ${{ env.REFEREE_DATA }}
154290 retention-days : 90
155291
292+ # Unconditional, unlike the referee steps: on a pull request this is the check that the
293+ # tutorial still builds. Placed after the provenance fold so that `home_page/` and
294+ # `LMLTutorial/_out/` do not exist yet when the ledger records whether the tree was clean.
295+ - name : Build Verso Documentation
296+ run : |
297+ ./scripts/build_docs.sh
298+
156299 - name : Compile blueprint and documentation
157300 uses : leanprover-community/docgen-action@7b5b9a1822650bd45aaeb4182d94bceba2030f89 # 2026-04-14
158301 with :
0 commit comments