Skip to content

Commit 5fbef42

Browse files
committed
quarto book template
1 parent 5358304 commit 5fbef42

File tree

8 files changed

+200
-169
lines changed

8 files changed

+200
-169
lines changed

.github/ci.jl

Lines changed: 101 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,17 @@
1-
using Literate
2-
using JSON
3-
using Pkg
1+
using Distributed
2+
using Tables
3+
using MarkdownTables
4+
using SHA
45

5-
ENV["GKSwstype"] = "100"
6-
7-
function main(; rmsvg=true)
8-
file = get(ENV, "NB", "test.ipynb")
9-
cachedir = get(ENV, "NBCACHE", ".cache")
10-
nb = if endswith(file, ".jl")
11-
run_literate(file; cachedir)
12-
elseif endswith(file, ".ipynb")
13-
lit = to_literate(file)
14-
run_literate(lit; cachedir)
15-
else
16-
error("$(file) is not a valid notebook file!")
17-
end
18-
rmsvg && strip_svg(nb)
19-
return nothing
6+
@everywhere begin
7+
ENV["GKSwstype"] = "100"
8+
using Literate, JSON
209
end
2110

2211
# Strip SVG output from a Jupyter notebook
23-
function strip_svg(ipynb)
24-
@info "Stripping SVG in $(ipynb)"
25-
nb = open(JSON.parse, ipynb, "r")
12+
@everywhere function strip_svg(nbpath)
13+
oldfilesize = filesize(nbpath)
14+
nb = open(JSON.parse, nbpath, "r")
2615
for cell in nb["cells"]
2716
!haskey(cell, "outputs") && continue
2817
for output in cell["outputs"]
@@ -34,13 +23,34 @@ function strip_svg(ipynb)
3423
end
3524
end
3625
end
37-
rm(ipynb; force=true)
38-
write(ipynb, JSON.json(nb, 1))
39-
return ipynb
26+
rm(nbpath; force=true)
27+
write(nbpath, JSON.json(nb, 1))
28+
@info "Stripped SVG in $(nbpath). The original size is $(oldfilesize). The new size is $(filesize(nbpath))."
29+
return nbpath
30+
end
31+
32+
# Remove cached notebook and sha files if there is no corresponding notebook
33+
function clean_cache(cachedir)
34+
for (root, _, files) in walkdir(cachedir)
35+
for file in files
36+
fn, ext = splitext(file)
37+
if ext == ".sha"
38+
target = joinpath(joinpath(splitpath(root)[2:end]), fn)
39+
nb = target * ".ipynb"
40+
lit = target * ".jl"
41+
if !isfile(nb) && !isfile(lit)
42+
cachepath = joinpath(root, fn)
43+
@info "Notebook $(nb) or $(lit) not found. Removing $(cachepath) SHA and notebook."
44+
rm(cachepath * ".sha")
45+
rm(cachepath * ".ipynb"; force=true)
46+
end
47+
end
48+
end
49+
end
4050
end
4151

4252
# Convert a Jupyter notebook into a Literate notebook. Adapted from https://github.com/JuliaInterop/NBInclude.jl.
43-
function to_literate(nbpath; shell_or_help = r"^\s*[;?]")
53+
function to_literate(nbpath; shell_or_help=r"^\s*[;?]")
4454
nb = open(JSON.parse, nbpath, "r")
4555
jlpath = splitext(nbpath)[1] * ".jl"
4656
open(jlpath, "w") do io
@@ -62,12 +72,77 @@ function to_literate(nbpath; shell_or_help = r"^\s*[;?]")
6272
return jlpath
6373
end
6474

65-
function run_literate(file; cachedir = ".cache")
75+
# List notebooks without caches in a file tree
76+
function list_notebooks(basedir, cachedir)
77+
list = String[]
78+
for (root, _, files) in walkdir(basedir)
79+
for file in files
80+
name, ext = splitext(file)
81+
if ext == ".ipynb" || ext == ".jl"
82+
nb = joinpath(root, file)
83+
shaval = read(nb, String) |> sha256 |> bytes2hex
84+
@info "$(nb) SHA256 = $(shaval)"
85+
shafilename = joinpath(cachedir, root, name * ".sha")
86+
if isfile(shafilename) && read(shafilename, String) == shaval
87+
@info "$(nb) cache hits and will not be executed."
88+
else
89+
@info "$(nb) cache misses. Writing hash to $(shafilename)."
90+
mkpath(dirname(shafilename))
91+
write(shafilename, shaval)
92+
if ext == ".ipynb"
93+
litnb = to_literate(nb)
94+
rm(nb; force=true)
95+
push!(list, litnb)
96+
elseif ext == ".jl"
97+
push!(list, nb)
98+
end
99+
end
100+
end
101+
end
102+
end
103+
return list
104+
end
105+
106+
# Run a Literate notebook
107+
@everywhere function run_literate(file, cachedir; rmsvg=true)
66108
outpath = joinpath(abspath(pwd()), cachedir, dirname(file))
67109
mkpath(outpath)
68110
ipynb = Literate.notebook(file, dirname(file); mdstrings=true, execute=true)
111+
rmsvg && strip_svg(ipynb)
69112
cp(ipynb, joinpath(outpath, basename(ipynb)); force=true)
70113
return ipynb
71114
end
72115

116+
function main(;
117+
basedir=get(ENV, "DOCDIR", "docs"),
118+
cachedir=get(ENV, "NBCACHE", ".cache"),
119+
rmsvg=true)
120+
121+
mkpath(cachedir)
122+
clean_cache(cachedir)
123+
litnbs = list_notebooks(basedir, cachedir)
124+
125+
if !isempty(litnbs)
126+
# Execute literate notebooks in worker process(es)
127+
ts_lit = pmap(litnbs; on_error=identity) do nb
128+
@elapsed run_literate(nb, cachedir; rmsvg)
129+
end
130+
failed = false
131+
for (nb, t) in zip(litnbs, ts_lit)
132+
if t isa ErrorException
133+
println("Notebook: ", nb, "failed with error: \n", t.msg)
134+
failed = true
135+
end
136+
end
137+
138+
if failed
139+
error("Please check literate notebook error(s).")
140+
else
141+
# Print execution result
142+
Tables.table([litnbs ts_lit]; header=["Notebook", "Elapsed (s)"]) |> markdown_table(String) |> print
143+
end
144+
end
145+
end
146+
147+
# Run code
73148
main()

.github/workflows/ci.yml

Lines changed: 66 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
name: CI with dynamic parallel matrix
1+
name: Build notebooks and publish
22

33
on:
44
workflow_dispatch:
@@ -12,20 +12,18 @@ concurrency:
1212
cancel-in-progress: true
1313

1414
env:
15-
NBCACHE: ".cache"
15+
JULIA_CI: 'true'
1616
JULIA_CONDAPKG_BACKEND: 'Null'
1717
JULIA_CONDAPKG_OFFLINE: 'true'
18-
JULIA_CI: 'true'
19-
JULIA_NUM_THREADS: 'auto'
18+
JULIA_NUM_THREADS: '2'
19+
JULIA_CPU_TARGET: 'generic;icelake-server,clone_all;znver3,clone_all'
20+
NBCACHE: '.cache'
21+
LITERATE_PROC: '2'
2022
PY_VER: '3.13'
2123

2224
jobs:
23-
setup:
25+
execute:
2426
runs-on: ubuntu-latest
25-
outputs:
26-
matrix: ${{ steps.set-matrix.outputs.matrix }}
27-
hash: ${{ steps.hash.outputs.value }}
28-
ver: ${{ steps.julia-version.outputs.resolved }}
2927
steps:
3028
- name: Checkout repository
3129
uses: actions/checkout@v6
@@ -40,123 +38,87 @@ jobs:
4038
run: uv pip install --system -r requirements.txt
4139
- name: Read Julia version
4240
id: julia-version
43-
run: python -c 'import tomllib; from pathlib import Path; print("resolved=", tomllib.loads(Path("Manifest.toml").read_text())["julia_version"], sep="")' >> "$GITHUB_OUTPUT"
41+
run: python -c 'import tomllib; from pathlib import Path; print("value=", tomllib.loads(Path("Manifest.toml").read_text())["julia_version"], sep="")' >> "$GITHUB_OUTPUT"
4442
- name: Get environment hash
4543
id: hash
4644
run: |
4745
echo "value=${{ hashFiles('Project.toml', 'Manifest.toml', 'src/**') }}" >> "$GITHUB_OUTPUT"
48-
echo "ver=${{ runner.os }}-julia-${{ steps.julia-version.outputs.resolved }}" >> "$GITHUB_OUTPUT"
46+
- name: Cache executed notebooks
47+
uses: actions/cache@v4
48+
id: cache-nb
49+
with:
50+
path: |
51+
${{ env.NBCACHE }}/**/*.ipynb
52+
${{ env.NBCACHE }}/**/*.sha
53+
key: notebook-${{ steps.hash.outputs.value }}-${{ hashFiles('docs/**') }}
54+
restore-keys: |
55+
notebook-${{ steps.hash.outputs.value }}-
4956
- name: Setup Julia
5057
uses: julia-actions/setup-julia@v2
5158
with:
52-
version: ${{ steps.julia-version.outputs.resolved }}
59+
version: ${{ steps.julia-version.outputs.value }}
5360
arch: ${{ runner.arch }}
54-
- name: Cache Julia packages
55-
uses: actions/cache@v4
61+
- name: Restore Julia packages
62+
uses: actions/cache/restore@v4
63+
if: ${{ runner.environment == 'github-hosted' }}
5664
id: cache-julia
5765
with:
5866
path: ~/.julia
59-
key: ${{ runner.os }}-julia-${{ steps.julia-version.outputs.resolved }}-${{ steps.hash.outputs.value }}
67+
key: ${{ runner.os }}-julia-${{ steps.julia-version.outputs.value }}-${{ steps.hash.outputs.value }}
6068
restore-keys: |
61-
${{ runner.os }}-julia-${{ steps.julia-version.outputs.resolved }}-
69+
${{ runner.os }}-julia-${{ steps.julia-version.outputs.value }}
6270
- name: Install Julia packages
63-
if: ${{ steps.cache-julia.outputs.cache-hit != 'true' }}
64-
shell: julia --color=yes --project=@. {0}
65-
env:
66-
JULIA_DEBUG: loading
71+
if: ${{ runner.environment == 'self-hosted' || steps.cache-julia.outputs.cache-hit != 'true' }}
72+
shell: julia --color=yes {0}
6773
run: |
6874
using Pkg, Dates
75+
Pkg.add(["Literate", "JSON", "Tables", "MarkdownTables", "SHA"])
76+
Pkg.activate(".")
6977
Pkg.instantiate()
7078
Pkg.precompile()
7179
if ENV["RUNNER_ENVIRONMENT"] == "github-hosted"
7280
Pkg.gc(;collect_delay=Day(0))
7381
end
74-
- name: List notebooks as a JSON array
75-
id: set-matrix
76-
run: echo "matrix=$(python -c 'import glob, json; print(json.dumps(glob.glob("**/*.ipynb", root_dir="docs", recursive=True) + glob.glob("**/*.jl", root_dir="docs", recursive=True)))')" >> "$GITHUB_OUTPUT"
77-
78-
execute:
79-
needs: setup
80-
strategy:
81-
max-parallel: 10
82-
fail-fast: false
83-
matrix:
84-
notebook: ${{ fromJSON(needs.setup.outputs.matrix) }}
85-
runs-on: ubuntu-latest
86-
env:
87-
NB: docs/${{ matrix.notebook }}
88-
steps:
89-
- name: Checkout
90-
uses: actions/checkout@v6
91-
- name: Cache notebook
92-
uses: actions/cache@v4
93-
id: nb-cache
94-
with:
95-
path: ${{ env.NBCACHE }}
96-
key: notebook-${{ needs.setup.outputs.hash }}-${{ hashFiles(env.NB) }}
97-
- name: Setup Python
98-
uses: actions/setup-python@v6
99-
if: ${{ steps.nb-cache.outputs.cache-hit != 'true' }}
100-
id: setup-python
101-
with:
102-
python-version: ${{ env.PY_VER }}
103-
- name: Install the latest version of uv
104-
if: ${{ steps.nb-cache.outputs.cache-hit != 'true' }}
105-
uses: astral-sh/setup-uv@v7
106-
- name: Install Python dependencies
107-
if: ${{ steps.nb-cache.outputs.cache-hit != 'true' }}
108-
run: uv pip install --system -r requirements.txt
109-
- name: Setup Julia
110-
uses: julia-actions/setup-julia@v2
111-
if: ${{ steps.nb-cache.outputs.cache-hit != 'true' }}
112-
with:
113-
version: ${{ needs.setup.outputs.ver }}
114-
arch: ${{ runner.arch }}
115-
- name: Restore Julia packages
116-
uses: actions/cache/restore@v4
117-
if: ${{ steps.nb-cache.outputs.cache-hit != 'true' }}
118-
with:
119-
path: ~/.julia
120-
key: ${{ runner.os }}-julia-${{ needs.setup.outputs.ver }}-${{ needs.setup.outputs.hash }}
121-
- name: Execute notebook
122-
if: ${{ steps.nb-cache.outputs.cache-hit != 'true' }}
123-
run: julia --project=@. .github/ci.jl
124-
- name: Convert artifact Name
125-
id: art
126-
run: echo "name=$(echo ${{ env.NB }} | sed 's/\//-/g')" >> "$GITHUB_OUTPUT"
127-
- name: Upload Notebook
128-
uses: actions/upload-artifact@v5
129-
with:
130-
name: notebook-${{ steps.art.outputs.name }}-${{ needs.setup.outputs.hash }}-${{ hashFiles(env.NB) }}
131-
path: ${{ env.NBCACHE }}
132-
include-hidden-files: true
133-
retention-days: 1
134-
82+
- name: Save Julia packages
83+
uses: actions/cache/save@v4
84+
if: ${{ runner.environment == 'github-hosted' && steps.cache-julia.outputs.cache-hit != 'true' }}
85+
with:
86+
path: ~/.julia
87+
key: ${{ steps.cache-julia.outputs.cache-primary-key }}
88+
- name: Run notebooks
89+
if: ${{ steps.cache-nb.outputs.cache-hit != 'true' }}
90+
run: julia --project=@. --color=yes -p ${{ env.LITERATE_PROC }} .github/ci.jl
91+
- name: Upload Notebooks
92+
uses: actions/upload-artifact@v5
93+
with:
94+
path: ${{ env.NBCACHE }}/**/*.ipynb
95+
include-hidden-files: true
96+
name: notebooks
97+
retention-days: 1
13598
render:
13699
needs: execute
137100
runs-on: ubuntu-latest
138101
steps:
139-
- name: Checkout
140-
uses: actions/checkout@v6
141-
- name: Download notebooks
142-
uses: actions/download-artifact@v6
143-
with:
144-
path: ${{ env.NBCACHE }}/
145-
pattern: notebook-*
146-
merge-multiple: true
147-
- name: Copy back built notebooks
148-
run: cp --verbose -rf ${{ env.NBCACHE }}/docs/* docs/
149-
- name: Setup Quarto
150-
uses: quarto-dev/quarto-actions/setup@v2
151-
- name: Remove jl files to avoid confusion with jupytext
152-
run: find docs/ -type f -name "*.jl" -delete
153-
- name: Render Quarto Project
154-
run: quarto render docs --to html
155-
- name: Upload artifact for GH pages
156-
uses: actions/upload-pages-artifact@v4
157-
if: ${{ github.ref == 'refs/heads/main' }}
158-
with:
159-
path: _site/
102+
- name: Checkout
103+
uses: actions/checkout@v6
104+
- name: Download notebooks
105+
uses: actions/download-artifact@v6
106+
with:
107+
name: notebooks
108+
path: ${{ env.NBCACHE }}
109+
- name: Copy back built notebooks
110+
run: cp --verbose -rf ${{ env.NBCACHE }}/docs/* docs/
111+
- name: Setup Quarto
112+
uses: quarto-dev/quarto-actions/setup@v2
113+
- name: Remove all jl files preventing Quarto from rendering them
114+
run: find docs/ -type f -name '*.jl' -delete
115+
- name: Render Quarto Project
116+
run: quarto render docs/ --to html
117+
- name: Upload pages artifact
118+
uses: actions/upload-pages-artifact@v4
119+
if: ${{ github.ref == 'refs/heads/main' }}
120+
with:
121+
path: _site/
160122

161123
# CI conclusion for GitHub status check
162124
# Adaped from https://brunoscheufler.com/blog/2022-04-09-the-required-github-status-check-that-wasnt
@@ -174,16 +136,14 @@ jobs:
174136
exit 1
175137
fi
176138
177-
# Deployment job
178139
deploy:
179140
name: Deploy to GitHub pages
180141
needs: render
181-
if: ${{ github.ref == 'refs/heads/main' }}
142+
if: ${{ github.ref == 'refs/heads/main'}}
182143
# Grant GITHUB_TOKEN the permissions required to make a Pages deployment
183144
permissions:
184-
pages: write # to deploy to Pages
145+
pages: write # to deploy to Pages
185146
id-token: write # to verify the deployment originates from an appropriate source
186-
actions: read # to download an artifact uploaded by `actions/upload-pages-artifact@v3`
187147
environment:
188148
name: github-pages
189149
url: ${{ steps.deployment.outputs.page_url }}

0 commit comments

Comments
 (0)