ci: split notebook execution into its own workflow with cached artifact (closes #186) - #241
Conversation
Adds .github/workflows/execute-notebooks.yml as a source-hash-cached notebook-execution stage, and reworks build-combined-doc.yml to consume its "executed_nbs" artifact instead of re-executing notebooks itself. Solves three related problems at once (see issue #186): 1. Doc PRs and merges don't currently execute notebooks — only the release workflow does. This makes notebook execution a first-class CI stage that fires on every main push affecting notebook or library source. 2. Committed executed-notebook outputs drift from committed source by convention only. This pattern moves executed outputs out of git entirely — the artifact IS the source of truth, and the source-hash cache key guarantees outputs are always regenerated when any input that could affect them changes. 3. Every doc build re-executes notebooks (~25 min per build). Under this pattern the doc build downloads a fresh artifact (~seconds) and only does site build + concat. Execute Notebooks only re-runs when notebook / library / pyproject source hashes change. ## Workflow shape Execute Notebooks ├─ trigger: push touching docs/**/*.ipynb, src/**, pyproject.toml ├─ cache-key: hashFiles(all *.ipynb, src/**, pyproject.toml) ├─ on cache miss: make docs-executed-nbs ├─ gate: python docs/check_executed_nbs.py (fails on any nb error) └─ upload artifact "executed_nbs", retention: 400 days Build Combined Doc ├─ trigger: workflow_run completion of Execute Notebooks (auto-chain) │ OR manual workflow_dispatch (grabs latest successful) ├─ download executed_nbs artifact (no re-execution) └─ make docs-jenner-artifact → build site + concat → sync to laser-mcp ## Makefile Adds `docs-jenner-artifact` — the CI-side counterpart to `docs-jenner` (local: assumes committed outputs) and `docs-jenner-execute` (local: full execute + check + build + concat). Same output as docs-jenner but skips both docs-executed-nbs and docs-check-nbs — assumes $(EXEC_DIR) is pre-populated by the artifact download. ## Bootstrap note On first enable, workflow_dispatch on Build Combined Doc will fail cleanly (no prior successful Execute Notebooks artifact to consume — the download step's `if_no_artifact_found: fail` catches this). Kick off Execute Notebooks once manually to seed. After that, either trigger works. ## Merge order with in-flight PRs - Independent of #204 (plot descriptions), #240 (figtext), #236 (concat improvements) — those change content; this changes CI plumbing. - Compatible with #237 (skip re-exec in docs-jenner) — that PR is about local dev; this pattern replaces CI-side reasoning about executed outputs entirely. Both land cleanly. Closes #186. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…itative) Adds a policy block to execute-notebooks.yml stating that committed notebook outputs are informational only (GitHub-render friendliness) and have no effect on CI-produced artifacts. Contributors may commit executed or stripped notebooks interchangeably; both work. Documents this so future maintainers understand the tradeoff — repo bloat + PR-review noise + possible drift accepted, in exchange for browsing-on-github.com rendering and no developer friction. If a future decision reverses this (Option B), the enforcement point is identified inline: a single check step in github-actions.yml that fails PRs whose notebooks contain non-empty outputs. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Re-executed all 18 tutorial notebooks with the figtext-injected source (committed in 285308b) and committed the resulting outputs. Every plot now shows the takeaway sentence as an 8pt caption below the axes, matching what a fresh laser-generic doc build would render. Motivation: - Committed notebook outputs were rendered before figtext calls were added, so the previously-committed figure PNGs didn't reflect the source. Someone browsing docs/tutorials/notebooks/*.ipynb on github.com would see charts without captions, while a fresh `make docs-jenner-execute` would render them WITH captions. - Under the "committed outputs are decorative" policy proposed for laser-generic PR #241, this doesn't affect any downstream doc/RAG build (those consume the Execute Notebooks CI artifact). But since github.com renders committed notebooks inline, keeping outputs fresh preserves the browsing experience. Change shape: - 18 notebook files, source identical (verified byte-for-byte via a cell-by-cell comparison against 285308b), outputs replaced with freshly-executed versions. - EW_analysis.ipynb untouched (excluded from the docs-executed-nbs pass via NB_EXCLUDE). - Execution environment: GITHUB_ACTIONS=true, so nb06 used the env-var-lite path (n_years=20, nsims=2) introduced in #233 — matching what CI would produce. Verified caption placement in nb01 (3 plots) and nb06 (2 plots): 8pt figtext below the axes, wraps to ~2 lines, LaTeX ($T ≈ 2\pi\sqrt{AG}$, Greek subscripts) renders correctly. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR refactors the documentation CI pipeline by moving notebook execution into a dedicated workflow that publishes executed notebooks as a long-retention artifact, and updating the combined-doc build workflow to consume that artifact rather than re-executing notebooks.
Changes:
- Adds a new “Execute Notebooks” workflow that executes notebooks, gates on execution errors, caches results, and uploads an
executed_nbsartifact. - Updates “Build Combined Doc” to auto-chain off the notebook-execution workflow (or run manually) and to build/concat from the downloaded artifact.
- Adds a
docs-jenner-artifactMakefile target for building/concatenating whendist/executed_nbsis already populated.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| Makefile | Adds docs-jenner-artifact to build+concat from a pre-populated executed-notebooks tree. |
| .github/workflows/execute-notebooks.yml | New workflow to execute notebooks, cache by source hash, gate on errors, and upload a long-retention artifact. |
| .github/workflows/build-combined-doc.yml | Downloads executed_nbs artifact (per-trigger strategy) and builds the combined doc without notebook execution. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| - 'docs/**/*.ipynb' # notebook source changed | ||
| - 'src/**' # library that notebooks import changed | ||
| - 'pyproject.toml' # dependencies changed | ||
| - 'docs/execute_notebooks.py' # exec harness itself changed | ||
| - 'docs/check_executed_nbs.py' # error gate changed | ||
| - '.github/workflows/execute-notebooks.yml' |
There was a problem hiding this comment.
Good catch — addressed in 4fa876f. Added docs/requirements.txt and Makefile to the paths filter.
| path: dist/executed_nbs | ||
| key: executed-nbs-v1-${{ hashFiles('docs/**/*.ipynb', 'src/**/*.py', 'pyproject.toml') }} | ||
|
|
There was a problem hiding this comment.
Fixed in 4fa876f. Added docs/requirements.txt, docs/execute_notebooks.py, and Makefile to the cache-key hashFiles list and bumped the key prefix v1 -> v2 to invalidate historic pool entries built under the narrower key.
| # Skip if the upstream Execute Notebooks run failed (no artifact worth | ||
| # consuming). workflow_dispatch triggers pass this check trivially. | ||
| if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }} |
There was a problem hiding this comment.
Good call. Fixed in 4fa876f: restricted the workflow_run auto-chain to github.event.workflow_run.event == 'push', so upstream workflow_dispatch runs (debug dispatches with allow_notebook_errors=true etc.) no longer auto-fire this workflow. Manual doc rebuilds still work via this workflow's own workflow_dispatch trigger.
Three related fixes covering staleness and safety: 1. Path filter (execute-notebooks.yml): add docs/requirements.txt and Makefile. Changes to either can affect executed-notebook outputs (via docs-install picking up new deps, or Makefile NB_EXCLUDE / NB_TIMEOUT changes) but previously wouldn't trigger the workflow. 2. Cache key (execute-notebooks.yml): add docs/requirements.txt, docs/execute_notebooks.py, and Makefile to the hashFiles set. Without these, editing (e.g.) the executor script would trigger the workflow via the paths filter but still hit an old cache entry, producing outputs from the pre-change executor. Bumped key prefix v1 -> v2 to invalidate any historic pool built under the narrower key. 3. workflow_run auto-chain (build-combined-doc.yml): restrict to push-triggered upstream Execute Notebooks runs. A debug dispatch of Execute Notebooks (allow_notebook_errors=true or force_reexecute=true) would previously auto-fire Build Combined Doc and push a possibly-corrupted corpus PR into laser-mcp. Manual doc rebuilds continue to work via this workflow's own workflow_dispatch trigger. Also added a top-of-file comment on the path filter making the "kept in sync with the cache-key hashFiles below" contract explicit, so future edits to either side are more likely to update both. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Five changes tightening the artifact contract, per detailed review:
1. PR validation trigger. Adds pull_request to execute-notebooks.yml with
the same paths filter. PR runs execute notebooks and run the error
gate — reviewers see breakage before merge, not after. PR-scoped
artifact name (executed_nbs-pr-<N>) prevents accidental pollution of
the canonical artifact stream.
2. Artifact manifest. execute-notebooks now writes
dist/executed_nbs/manifest.json before upload, containing:
schema_version, commit_sha, commit_ref, source_hash, python_version,
run_id, run_number, workflow_url, event_name, created_at,
was_cache_hit, was_forced, was_allow_errors.
Consumers can verify provenance and detect drift.
3. Cache-key correctness. Extended hashFiles set to include docs/**/*.py
(defensive against future doc scripts) beyond the docs/requirements.txt
/ Makefile additions from the prior fix. Source hash is now computed
ONCE via a `Compute source hash` step (id: source-hash) and reused in
both the cache key and the manifest — no chance of the two drifting.
Bumped key prefix v2 -> v3 for the new hash set.
4. Non-canonical upload on failure/debug. Split what used to be a single
`if: always()` upload of `executed_nbs` into four conditional uploads
under distinct names, so Build Combined Doc's "latest successful"
lookup can never match a poisoned artifact:
canonical `executed_nbs` (400d) <- push-to-main OR clean dispatch
`executed_nbs-pr-<N>` (90d) <- pull_request success
`executed_nbs-debug-<run>` (30d) <- dispatch w/ allow_notebook_errors
`executed_nbs-failed-<run>` (30d) <- any failure path
5. Compatibility gate on manual dispatch (build-combined-doc.yml).
workflow_dispatch now computes hashFiles of the checked-out tree and
compares against the artifact's manifest.source_hash. Mismatch fails
the build with an actionable error message. New workflow input
`use_latest_anyway` (default false) bypasses the check for cases where
building against an older artifact is intentional. workflow_run path
is unaffected — it already fetches by explicit run-id.
Kept in sync: the paths filter (pull_request + push) and the source-hash
step's hashFiles list are literal duplicates by design. Comment above
Compute source hash notes that all three lists must move together.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Addressed the 5 guardrails in
|
| # | Concern | Fix |
|---|---|---|
| 1 | PR validation gap | Added pull_request trigger to execute-notebooks.yml with the same paths filter. PR runs execute notebooks + run the error gate. Uploads to executed_nbs-pr-<N> (distinct name) so it can't be picked up by Build Combined Doc's "latest successful" lookup. No laser-mcp sync side effects — that lives in build-combined-doc.yml, unchanged for PR runs. |
| 2 | Artifact provenance | Writes dist/executed_nbs/manifest.json before upload with schema_version, commit_sha, commit_ref, source_hash, python_version, run_id, run_number, workflow_url, event_name, created_at, was_cache_hit, was_forced, was_allow_errors. Placed inside the artifact so download-artifact includes it automatically. |
| 3 | Cache correctness | Extended hashFiles set: added docs/**/*.py (catches future doc scripts). No mkdocs.yml because it's only used by docs-build after execution (mkdocs-jupyter runs with execute=false). No lockfile because none exists in the repo today. Refactored to compute the hash ONCE in a Compute source hash step (id: source-hash) — the cache key AND the manifest read the same output, so they can't drift. Bumped key prefix v2 -> v3 for the new hash set. |
| 4 | if: always() upload risky |
Split into four conditional uploads under distinct names: executed_nbs (canonical, push-to-main OR clean dispatch, 400d), executed_nbs-pr-<N> (90d), executed_nbs-debug-<run_id> (dispatch with allow_notebook_errors=true, 30d), executed_nbs-failed-<run_id> (any failure, 30d). Build Combined Doc's "latest successful" lookup can only ever match the canonical name. |
| 5 | Manual dispatch compat check | New step in build-combined-doc.yml: when triggered by workflow_dispatch, computes hashFiles of the checkout with the same file set, compares to manifest.source_hash, fails on mismatch with an actionable error. New input use_latest_anyway (default false) bypasses the check for deliberate stale-artifact builds. workflow_run path unaffected — it already fetches by explicit run-id, no drift possible. |
The three lists that must stay in sync (paths filter × 2 triggers + hashFiles) are literal duplicates by design; comment on the Compute source hash step calls this out for future editors.
CI checks pending on the new tip. Ready for another look.
Undoes point 1 of the earlier 5-guardrail commit. Executing every notebook adds ~25 min of wall-clock per PR iteration, which is not affordable during review cycles. The alpha suite already exercises the RAG corpus end-to-end, and post-merge push runs surface any notebook breakage within minutes of landing on main. Kept the top-of-file comment explicitly stating this is a deliberate choice, so a future reviewer doesn't re-add the trigger without knowing the cost tradeoff. Local `make docs-jenner-execute` and manual workflow_dispatch remain as pre-merge validation options for PRs that specifically need them. Also removed the now-orphan `executed_nbs-pr-<N>` upload step. The remaining four artifact names still cleanly separate canonical from debug paths. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Reverted guardrail #1 (pull_request trigger) in
|
| @echo " make docs-jenner Full pipeline (execute + check + build + concat)" | ||
| @echo " Output: $(COMBINED)" | ||
| @echo " make docs-jenner-artifact Build + concat only (assumes \$$(EXEC_DIR) pre-populated)" | ||
| @echo " Used by the Execute Notebooks -> Build Combined Doc CI chain" |
There was a problem hiding this comment.
Correct — the 'trio' framing in the PR description was aspirational: it only applies after #237 also lands (that PR is what renames the current docs-jenner into docs-jenner-execute and adds the fast docs-jenner). This PR alone only adds docs-jenner-artifact. Fixing the PR description to reflect that, rather than stepping on #237's Makefile changes here.
Two related fixes: 1. execute-notebooks.yml cache key: add .github/workflows/execute-notebooks.yml to the hashFiles set. The workflow file is already in the push paths filter, but was missing from the cache key — meaning a change to (e.g.) python-version, env vars, or command semantics in the workflow would trigger a run but hit the old cache entry and skip re-execution. Bumped cache key prefix v3 -> v4 to invalidate historic pool. 2. build-combined-doc.yml compat check: mirror the same addition in the provenance-hash comparison, so the workflow_dispatch mismatch check stays aligned with what execute-notebooks.yml actually hashes. The two hashFiles lists must be identical or the check produces false positives/negatives. Both lists are now literal duplicates by design — 7 patterns each, same order. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…vars (#244) The manifest-write step in Execute Notebooks failed on its first post-#241 push run (29054294479) with `NameError: name 'false' is not defined`. The heredoc substituted `\${{ ... }}` expressions directly into Python source, but GHA renders booleans as lowercase `true`/`false` — invalid Python identifiers. Fix: pass every substitution through an `env:` block, read via `os.environ` in Python, and route booleans through an `as_bool()` helper that compares against the string `"true"`. Removes the substitution-into-source hazard entirely — no future field addition can silently break the surrounding Python. Verified locally by running the step's Python code with representative env values → produces a valid manifest.json. Design safety confirmed on the failed run: only the non-canonical `executed_nbs-failed-<run_id>` artifact uploaded (via `if: failure()`), canonical `executed_nbs` upload correctly skipped, Build Combined Doc's workflow_run trigger saw `conclusion: failure` and skipped its job — no half-baked artifact went downstream to laser-mcp. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
) Follow-up to #241. Now that the Execute Notebooks -> Build Combined Doc chain is verified working end-to-end, makes the executed-notebooks artifact discoverable from two places without touching CI logic. ## Changes - **`README.md`** - New "Execute Notebooks" status badge alongside the existing build / coverage badges. - New "Executed notebook artifacts" section explaining what the artifact is, the 3-step download recipe, and pointing at `manifest.json` for provenance. - **`docs/tutorials/notebooks/README.md`** (new file) - Folder-local pointer to the same artifact download flow. - Clarifies the "committed outputs are decorative, source is authoritative" policy so contributors don't fight committed-output drift. - Documents the `manifest.json` provenance fields (commit_sha, source_hash, python_version, run_id, was_cache_hit, etc.). - Shows local regeneration commands including the `GITHUB_ACTIONS=true` toggle for nb06's lite path. Renders on github.com — anyone browsing `docs/tutorials/notebooks/` sees the same guidance a docs-site reader would. ## Verified working chain The pointers reference the actual working artifact pipeline that was verified in a full end-to-end pass after #241 landed: - Execute Notebooks run 29056479852 (cache miss, ~30 min): produced `executed_nbs` (4.48 MB canonical, `manifest.json` inside). - Execute Notebooks run 29057994100 (cache hit, ~3 min): confirmed the ~10x speedup on unchanged source. - Build Combined Doc run 29057992879: downloaded artifact, passed the provenance-check gate, built the combined doc, opened / updated the laser-mcp sync PR (#38 in laser-mcp). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The docs site at laser.idmod.org/laser-generic was being deployed by mkdocs-ghp.yml with `mkdocs-jupyter execute: false`, which meant it rendered whatever notebook `outputs` were committed in docs/tutorials/ notebooks/*.ipynb. Under the "committed outputs are decorative" policy adopted in #241, those outputs may drift from source over time — so the docs site could show stale figures while the RAG corpus (fed by the executed_nbs artifact via Build Combined Doc) had fresh ones. This inconsistency was called out during review of #245: the docs site is supposed to be the fresh, user-facing view. Fix: before `mkdocs build`, overlay the latest successful executed_nbs artifact onto the checked-out docs/ tree. Two download paths mirror Build Combined Doc's pattern: - workflow_run trigger: fetch the specific triggering run's artifact by run-id. Restricted to push-triggered upstream runs so debug dispatches don't auto-publish a suspect site. - push / workflow_dispatch trigger: fetch the latest successful Execute Notebooks artifact (may lag current commit if the push doesn't affect notebook execution — that's fine; a following workflow_run chain rebuilds when execution finishes). Bootstrap-safe: if_no_artifact_found: warn on the fallback path, so the very first deploy after enabling this (before any Execute Notebooks run has completed) still succeeds using committed decorative outputs. Emits a clear warning in the Actions log. The concurrency group (gh-pages-deploy, cancel-in-progress) already handles overlap: a push-triggered deploy that starts before Execute Notebooks completes will be superseded by the workflow_run chain firing with the fresher artifact. After this lands, the "docs site" row of the source-of-truth table in PR #245 becomes accurate: fresh, matching the RAG corpus, always rebuilt from CI-executed outputs. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The docs site at laser.idmod.org/laser-generic was being built with mkdocs-jupyter's `execute: false`, so it rendered whatever notebook `outputs` were committed under docs/tutorials/notebooks/*.ipynb. Under the "committed outputs are decorative" policy adopted in #241, those outputs may drift from source — so the docs site could show stale figures while the RAG corpus (fed by the executed_nbs artifact via Build Combined Doc) had fresh ones. Fix: before `mkdocs build`, download the latest successful executed_nbs artifact and overlay its `.ipynb` files onto the checked-out docs/ tree. Two download paths mirror Build Combined Doc: - workflow_run trigger: fetch by run-id from the triggering upstream Execute Notebooks run. Restricted to push-triggered, successful upstream runs so debug dispatches don't auto-publish a suspect site. - push / workflow_dispatch trigger: fetch latest successful via dawidd6/action-download-artifact@v6. `if_no_artifact_found: warn` on this path — bootstrap-safe: first-time-ever deploy still succeeds with committed decorative outputs and emits a clear warning. Permissions block explicitly grants `actions: read` alongside the existing `contents: write`. Both download-artifact steps need `actions: read` to reach OTHER workflow runs' artifacts; the previous implicit-default worked when no permissions block was set, but the existing explicit `contents: write` overrides defaults, so `actions: read` had to be added or downloads would 403 at runtime. The concurrency group (gh-pages-deploy, cancel-in-progress: true) already handles the overlap case: a push-triggered deploy that starts before Execute Notebooks completes will be superseded by the workflow_run chain firing with the fresher artifact. Validated locally (Option A test): downloaded the artifact from run 29057994100, applied the same overlay logic on a scratch worktree at pristine main, ran mkdocs build. nb06's rendered page contained the CI-executed "Regime robustness" figure (only exists if the notebook was actually executed) among six real plot figures. After this lands, the "docs site" row of the source-of-truth policy becomes accurate: docs site <- Execute Notebooks artifact (was: committed outputs) RAG corpus <- Execute Notebooks artifact (unchanged) committed .ipynb <- committed decorative outputs (Option A, unchanged) Enables PR #245 (README reframe) to make the docs-site freshness claim truthfully. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Summary
Splits notebook execution into its own workflow (
execute-notebooks.yml) with a source-hash-keyed cache, publishes results as a 400-day GitHub Actions artifact, and reworksbuild-combined-doc.ymlto consume that artifact instead of re-executing notebooks itself.Closes #186 (notebook execution on doc PRs/merges) and solves two adjacent problems as a side effect.
What this fixes
Workflow shape
Files changed
.github/workflows/execute-notebooks.yml— cache + execute + check + manifest + conditional uploads..github/workflows/build-combined-doc.yml— download artifact instead of re-executing, addworkflow_run.event == 'push'guard on auto-chain, add manifest-based compatibility check onworkflow_dispatch.Makefile— newdocs-jenner-artifacttarget that runs build + concat only, assuming$(EXEC_DIR)is pre-populated by the artifact download. This is the sole Makefile change here — the existingdocs-jennerfull pipeline is untouched. docs-jenner: skip notebook re-execution (~40 min → ~1 min), add opt-in -execute variant #237 separately reworksdocs-jennerinto a fast path and addsdocs-jenner-execute; the trio is only complete once both this PR and docs-jenner: skip notebook re-execution (~40 min → ~1 min), add opt-in -execute variant #237 land.Policy decision: committed outputs are decorative (Option A)
Documented inline in
execute-notebooks.yml. Under this proposal:nbstripout) — both work identically for the doc build.github-actions.yml. Explicitly not enforced today.No pull_request trigger — deliberate
Executing every notebook adds ~25 min per PR iteration. That cost is not affordable during review cycles. Broken notebooks land on main and are surfaced by the post-merge push run within minutes. Local
make docs-jenner-execute(available once #237 lands) or a manualworkflow_dispatchon this workflow remain as pre-merge validation options for PRs that specifically need them.Bootstrap note
On first enable,
workflow_dispatchon Build Combined Doc will fail cleanly (no prior successful Execute Notebooks artifact to consume —if_no_artifact_found: failcatches this). Kick off Execute Notebooks once manually to seed. After that, either trigger works.Third-party action
dawidd6/action-download-artifact@v3— needed becauseactions/download-artifactonly fetches from the current run or a known run-id, andworkflow_dispatchon Build Combined Doc has no upstream run-id to point at. This third-party action searches for "latest successful run of workflow X" — exactly the semantics we need. Widely used (~4M weekly downloads); if you'd rather avoid the dep, we can write ~20 lines ofgh apito find and fetch the latest artifact ID.Merge order with in-flight PRs
docs-jenner) — docs-jenner: skip notebook re-execution (~40 min → ~1 min), add opt-in -execute variant #237 is about local dev workflow (renames currentdocs-jennertodocs-jenner-execute, makes new fastdocs-jenner); this PR replaces CI-side reasoning about executed outputs entirely. Both land cleanly, and together produce a clean trio:make docs-jenner— local fast path, committed outputs (per docs-jenner: skip notebook re-execution (~40 min → ~1 min), add opt-in -execute variant #237)make docs-jenner-execute— local full pipeline (per docs-jenner: skip notebook re-execution (~40 min → ~1 min), add opt-in -execute variant #237)make docs-jenner-artifact— CI path, assumes$(EXEC_DIR)pre-populated (this PR)Test plan
make helpshows the new target.hashFileslists (cache key in execute-notebooks + compat check in build-combined-doc) are literal duplicates: 7 patterns in same order.workflow_dispatchgrabs the most recent artifact and passes the compat check.🤖 Generated with Claude Code