Skip to content

Commit 23ef144

Browse files
mmckyclaude
andauthored
Enable HTML builds and per-PR preview deploys (#6)
* Add build scaffold and HTML preview machinery The repo had no build configuration at all: main carried only the translation workflows, and each seed/* branch adds a single lectures/<name>.md against it. Nothing was buildable, so there was no way for a native-speaker reviewer to see a rendered page. Adds the Jupyter Book scaffold (_config.yml, _toc.yml, _static, intro.md, environment.yml) and three workflows: - ci.yml builds every PR and deploys it to gh-pages under pr-N/, then comments with a deep link to the lectures that PR translates. Ported from QuantEcon.manual, the only repo in the fleet serving previews off GitHub Pages rather than Netlify — this edition has no Netlify site and no repo secrets, while GITHUB_TOKEN already has write. - publish.yml deploys main to the gh-pages root with keep_files so live previews survive. - reap-previews.yml reconciles gh-pages against open PRs weekly. Two deliberate departures from QuantEcon.manual. Its preview job checks out the PR head; here that would miss the scaffold, since the seed branches were cut from an empty main, so the default merge ref is used instead. And its publish job holds the gh-pages concurrency lock across the whole conda solve and build; because GitHub evicts an already-pending run when a newer one queues into a group, that long hold drops queued cleanups and has left orphaned previews live on that site. publish.yml here splits build from deploy so the lock is held for seconds, and reap-previews.yml covers the remainder. _toc.yml is pruned at build time by scripts/prune_toc.py rather than maintained by hand: action-translation copies it from the English source verbatim whenever an upstream PR touches it, so a pruned copy committed here would be overwritten on the next sync. The build runs without -n -W. The translated lectures carry references into lectures that do not exist yet (writing_good_code, python_advanced_features, scipy, getting_started, oop_intro, need_for_speed, and the labels pyess_ex2 and oop_ex1); those must stay warnings until the translation is complete. The fa and fr editions relax the same flag for the same reason. _config.yml is fr's, with ml substituted and the shared English Google Analytics property dropped so an in-progress translation and its previews do not report into the published site's stream. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Omit the peaceiris cname input instead of passing false QuantEcon.manual passes `cname: false` intending "no CNAME on previews", but YAML stringifies that to "false" and the action writes a CNAME file containing the literal text `false` at the gh-pages root. GitHub then reads it as a custom domain and the site fails to serve. The first run of this workflow reproduced it exactly. It is masked in QuantEcon.manual because its publish.yml overwrites the root CNAME with the real domain. This edition has no custom domain, so nothing would ever correct it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Use the defaults conda channel, not default `default` is not a channel; conda adds it as a literal name and the build only resolves because conda still adds `defaults` implicitly, which it warns about and is deprecating. The zh-cn edition already uses `defaults`; en, fr and fa carry the same typo and should be fixed upstream. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent d76ad43 commit 23ef144

85 files changed

Lines changed: 13490 additions & 0 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
name: Build HTML Preview for PR
2+
on:
3+
pull_request:
4+
types: [opened, synchronize, reopened]
5+
pull_request_target:
6+
types: [closed]
7+
8+
# Ported from QuantEcon.manual, which is the only repo in the fleet serving
9+
# /pr-N/ previews off GitHub Pages (the other translations deploy previews to
10+
# Netlify, which needs a Netlify site and a NETLIFY_SITE_ID secret this repo
11+
# does not have). Every job that writes to gh-pages shares one concurrency
12+
# group with publish.yml and reap-previews.yml: peaceiris/actions-gh-pages
13+
# force-pushes from a SHA read at job start, so two unserialised writers
14+
# silently erase each other.
15+
#
16+
# Note the group only bounds the damage, it does not eliminate it: GitHub
17+
# evicts an already-PENDING run when a newer one queues into the group, so a
18+
# burst of merges can still drop a cleanup. That is what reap-previews.yml is
19+
# for. Keep the locked section as short as possible so the window stays small.
20+
jobs:
21+
build-preview:
22+
if: github.event.action != 'closed'
23+
runs-on: ubuntu-latest
24+
steps:
25+
# No `ref:` — deliberately different from QuantEcon.manual, which pins
26+
# the PR head. The seed/* translation branches were cut from an empty
27+
# main and carry only `lectures/<name>.md`; the build scaffold lives on
28+
# main. Checking out the default merge ref gives us PR content plus the
29+
# scaffold, and previews the post-merge state, which is what a reviewer
30+
# wants to see.
31+
- name: Checkout (PR merge ref)
32+
uses: actions/checkout@v4
33+
34+
- name: Setup Anaconda
35+
uses: conda-incubator/setup-miniconda@v3
36+
with:
37+
auto-update-conda: true
38+
auto-activate-base: true
39+
miniconda-version: 'latest'
40+
python-version: "3.13"
41+
environment-file: environment.yml
42+
activate-environment: quantecon
43+
44+
- name: Display Conda Environment Versions
45+
shell: bash -l {0}
46+
run: conda list
47+
48+
# `shell: bash -l {0}` is a custom shell spec, so GitHub does NOT inject
49+
# `-eo pipefail`. Without the explicit set, a step ending in anything
50+
# after the build reports that command's status and masks a failed build
51+
# — this produced months of green-on-broken CI in the zh-cn edition.
52+
- name: Prune TOC to translated lectures
53+
shell: bash -l {0}
54+
run: |
55+
set -eo pipefail
56+
python scripts/prune_toc.py lectures/_toc.yml
57+
58+
# No -n -W: a partial translation has unresolved cross-references into
59+
# lectures that do not exist yet (as of this commit: writing_good_code,
60+
# python_advanced_features, scipy, getting_started, oop_intro,
61+
# need_for_speed, and the labels pyess_ex2 and oop_ex1). Those must stay
62+
# warnings until the translation is complete. The fa and fr editions
63+
# relax the same flag for the same reason.
64+
- name: Build HTML
65+
shell: bash -l {0}
66+
run: |
67+
set -eo pipefail
68+
jb build lectures --path-output ./ --keep-going
69+
70+
- name: Upload Execution Reports
71+
uses: actions/upload-artifact@v4
72+
if: failure()
73+
with:
74+
name: execution-reports
75+
path: _build/html/reports
76+
77+
- name: Upload preview artifact
78+
uses: actions/upload-artifact@v4
79+
with:
80+
name: html-preview
81+
path: _build/html/
82+
83+
deploy-preview:
84+
needs: build-preview
85+
# Fork PRs get the build as a status check but no deploy: the
86+
# pull_request GITHUB_TOKEN is read-only for forks, so the push would fail
87+
# anyway, and a fork build should never hold the gh-pages lock.
88+
if: github.event.pull_request.head.repo.full_name == github.repository
89+
runs-on: ubuntu-latest
90+
permissions:
91+
contents: write
92+
pull-requests: write
93+
concurrency:
94+
group: gh-pages
95+
cancel-in-progress: false
96+
steps:
97+
- name: Download preview artifact
98+
uses: actions/download-artifact@v4
99+
with:
100+
name: html-preview
101+
path: _build/html/
102+
103+
- name: Deploy Preview
104+
uses: peaceiris/actions-gh-pages@v4
105+
with:
106+
github_token: ${{ secrets.GITHUB_TOKEN }}
107+
publish_dir: _build/html/
108+
destination_dir: pr-${{ github.event.number }}
109+
# The `cname` input is deliberately OMITTED, not set to false.
110+
# QuantEcon.manual passes `cname: false` intending "no CNAME", but
111+
# YAML stringifies that to "false" and peaceiris writes a CNAME file
112+
# containing the literal text `false` at the gh-pages ROOT — which
113+
# GitHub then reads as a custom domain and the whole site 404s. It is
114+
# masked there because publish.yml overwrites the root CNAME with the
115+
# real domain; this edition has no custom domain, so nothing would
116+
# ever correct it. Omitting the input writes no CNAME at all.
117+
force_orphan: false
118+
119+
- name: Comment PR
120+
uses: actions/github-script@v7
121+
if: success()
122+
with:
123+
script: |
124+
const prNumber = context.payload.pull_request.number;
125+
const base = `https://quantecon.github.io/lecture-python-programming.ml/pr-${prNumber}`;
126+
const commitSha = context.payload.pull_request.head.sha.substring(0, 7);
127+
128+
// Deep-link the lectures this PR translates, so the reviewer lands
129+
// on the rendered Malayalam page rather than the landing page.
130+
const files = await github.paginate(github.rest.pulls.listFiles, {
131+
owner: context.repo.owner,
132+
repo: context.repo.repo,
133+
pull_number: prNumber,
134+
});
135+
136+
const pages = files
137+
.filter(f => f.status !== 'removed')
138+
.map(f => f.filename)
139+
.filter(f => f.startsWith('lectures/') && f.endsWith('.md'))
140+
// skip underscore files (_static, _admonition) — not built pages
141+
.filter(f => !f.split('/').some(part => part.startsWith('_')))
142+
.map(f => {
143+
const rel = f.replace(/^lectures\//, '').replace(/\.md$/, '.html');
144+
// intro.md is the TOC root, served at the preview root
145+
const href = rel === 'intro.html' ? `${base}/` : `${base}/${rel}`;
146+
return `- [${rel}](${href})`;
147+
});
148+
149+
let body = `📖 **HTML build** - [view preview](${base}/) (${commitSha})`;
150+
if (pages.length > 0) {
151+
body += `\n\n**Translated pages in this PR:**\n${pages.join('\n')}`;
152+
}
153+
154+
await github.rest.issues.createComment({
155+
owner: context.repo.owner,
156+
repo: context.repo.repo,
157+
issue_number: prNumber,
158+
body: body
159+
});
160+
161+
cleanup-preview:
162+
if: github.event.action == 'closed'
163+
runs-on: ubuntu-latest
164+
permissions:
165+
contents: write
166+
concurrency:
167+
group: gh-pages
168+
cancel-in-progress: false
169+
steps:
170+
# pull_request_target, not pull_request: a pull_request-triggered job on
171+
# a closed fork PR gets a read-only token. This is safe only because the
172+
# job checks out gh-pages and never executes PR-authored code — do not
173+
# add a PR checkout or a build step here.
174+
- name: Checkout gh-pages
175+
uses: actions/checkout@v4
176+
with:
177+
ref: gh-pages
178+
token: ${{ secrets.GITHUB_TOKEN }}
179+
fetch-depth: 1
180+
181+
- name: Remove PR preview directory
182+
run: |
183+
set -eo pipefail
184+
PR_DIR="pr-${{ github.event.number }}"
185+
if [ ! -d "$PR_DIR" ]; then
186+
echo "Preview directory $PR_DIR not found — nothing to clean up"
187+
exit 0
188+
fi
189+
rm -rf "$PR_DIR"
190+
git config user.name "github-actions[bot]"
191+
git config user.email \
192+
"41898282+github-actions[bot]@users.noreply.github.com"
193+
git add .
194+
if git diff --staged --quiet; then
195+
echo "No changes to commit"
196+
exit 0
197+
fi
198+
git commit -m "Remove preview for closed PR #${{ github.event.number }}"
199+
for i in 1 2 3; do
200+
if git push; then
201+
echo "Successfully pushed cleanup changes"
202+
exit 0
203+
fi
204+
echo "Push attempt $i failed, rebasing and retrying..."
205+
git pull --rebase origin gh-pages
206+
sleep 5
207+
done
208+
echo "Failed to push cleanup after 3 attempts"
209+
exit 1
210+
211+
# Check the pushed branch, not the working tree — the local rm -rf
212+
# always succeeds, so only origin/gh-pages proves the cleanup landed.
213+
- name: Verify cleanup completion
214+
run: |
215+
set -eo pipefail
216+
PR_DIR="pr-${{ github.event.number }}"
217+
git fetch origin gh-pages
218+
if git ls-tree -d --name-only origin/gh-pages "$PR_DIR" | grep -q .; then
219+
echo "❌ Cleanup failed: $PR_DIR still exists on origin/gh-pages"
220+
exit 1
221+
fi
222+
echo "✅ Cleanup verified: $PR_DIR is absent from origin/gh-pages"

.github/workflows/publish.yml

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
name: Build & Publish to GH Pages
2+
on:
3+
push:
4+
branches:
5+
- main
6+
7+
# Split into a lock-free build and a short locked deploy, which is where this
8+
# diverges from QuantEcon.manual. There the concurrency group wraps the whole
9+
# publish job, so the gh-pages lock is held across the conda solve and the
10+
# Jupyter Book build (~2 minutes). GitHub evicts an already-PENDING run when a
11+
# newer one queues into a group, so that long hold evicts queued cleanups: a
12+
# merge train on 2026-08-02 dropped two runs in 68 seconds and left an orphan
13+
# preview live on that site. Locking only the deploy keeps the window seconds
14+
# long. reap-previews.yml covers what still slips through.
15+
jobs:
16+
build:
17+
runs-on: ubuntu-latest
18+
steps:
19+
- name: Checkout
20+
uses: actions/checkout@v4
21+
22+
- name: Setup Anaconda
23+
uses: conda-incubator/setup-miniconda@v3
24+
with:
25+
auto-update-conda: true
26+
auto-activate-base: true
27+
miniconda-version: 'latest'
28+
python-version: "3.13"
29+
environment-file: environment.yml
30+
activate-environment: quantecon
31+
32+
- name: Display Conda Environment Versions
33+
shell: bash -l {0}
34+
run: conda list
35+
36+
# See ci.yml for why `set -eo pipefail` is explicit and why the TOC is
37+
# pruned at build time rather than maintained in the repo.
38+
- name: Prune TOC to translated lectures
39+
shell: bash -l {0}
40+
run: |
41+
set -eo pipefail
42+
python scripts/prune_toc.py lectures/_toc.yml
43+
44+
- name: Build HTML
45+
shell: bash -l {0}
46+
run: |
47+
set -eo pipefail
48+
jb build lectures --path-output ./ --keep-going
49+
50+
- name: Upload Execution Reports
51+
uses: actions/upload-artifact@v4
52+
if: failure()
53+
with:
54+
name: execution-reports
55+
path: _build/html/reports
56+
57+
- name: Upload site artifact
58+
uses: actions/upload-artifact@v4
59+
with:
60+
name: html-site
61+
path: _build/html/
62+
63+
deploy:
64+
needs: build
65+
runs-on: ubuntu-latest
66+
permissions:
67+
contents: write
68+
concurrency:
69+
group: gh-pages
70+
cancel-in-progress: false
71+
steps:
72+
- name: Download site artifact
73+
uses: actions/download-artifact@v4
74+
with:
75+
name: html-site
76+
path: _build/html/
77+
78+
- name: Deploy with Preview Preservation
79+
uses: peaceiris/actions-gh-pages@v4
80+
with:
81+
github_token: ${{ secrets.GITHUB_TOKEN }}
82+
publish_dir: _build/html/
83+
# keep_files is mandatory: without it every push to main wipes the
84+
# live pr-N preview directories out from under open reviews.
85+
keep_files: true
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
name: Reap Orphaned PR Previews
2+
on:
3+
schedule:
4+
# Weekly, Monday 04:00 UTC — after any weekend merge activity has settled.
5+
- cron: '0 4 * * 1'
6+
workflow_dispatch:
7+
8+
# ci.yml deletes a preview when its PR closes, but that cleanup can be lost:
9+
# GitHub evicts an already-PENDING run when a newer one queues into the shared
10+
# gh-pages concurrency group, and cleanup only ever fires on the close event,
11+
# so nothing retries it. QuantEcon.manual has no equivalent to this job and
12+
# consequently serves preview directories for PRs merged months ago. This
13+
# reconciles gh-pages against the set of open PRs and deletes the strays.
14+
jobs:
15+
reap:
16+
runs-on: ubuntu-latest
17+
permissions:
18+
contents: write
19+
pull-requests: read
20+
concurrency:
21+
group: gh-pages
22+
cancel-in-progress: false
23+
steps:
24+
- name: Checkout gh-pages
25+
uses: actions/checkout@v4
26+
with:
27+
ref: gh-pages
28+
token: ${{ secrets.GITHUB_TOKEN }}
29+
fetch-depth: 1
30+
31+
- name: Remove previews for PRs that are no longer open
32+
env:
33+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
34+
run: |
35+
set -eo pipefail
36+
37+
open_prs=$(gh pr list --repo "$GITHUB_REPOSITORY" \
38+
--state open --limit 500 --json number --jq '.[].number')
39+
echo "Open PRs: ${open_prs:-none}"
40+
41+
reaped=0
42+
while IFS= read -r dir; do
43+
[ -n "$dir" ] || continue
44+
number="${dir#./pr-}"
45+
if grep -qx "$number" <<<"$open_prs"; then
46+
echo "keep $dir (PR #$number is open)"
47+
else
48+
echo "reap $dir (PR #$number is not open)"
49+
rm -rf "$dir"
50+
reaped=$((reaped + 1))
51+
fi
52+
done < <(find . -maxdepth 1 -type d -name 'pr-*')
53+
54+
if [ "$reaped" -eq 0 ]; then
55+
echo "No orphaned previews found"
56+
exit 0
57+
fi
58+
59+
git config user.name "github-actions[bot]"
60+
git config user.email \
61+
"41898282+github-actions[bot]@users.noreply.github.com"
62+
git add .
63+
if git diff --staged --quiet; then
64+
echo "Nothing staged after reaping $reaped director(ies)"
65+
exit 0
66+
fi
67+
git commit -m "Reap $reaped orphaned PR preview(s)"
68+
for i in 1 2 3; do
69+
if git push; then
70+
echo "Reaped $reaped orphaned preview(s)"
71+
exit 0
72+
fi
73+
echo "Push attempt $i failed, rebasing and retrying..."
74+
git pull --rebase origin gh-pages
75+
sleep 5
76+
done
77+
echo "Failed to push after 3 attempts"
78+
exit 1

0 commit comments

Comments
 (0)