|
| 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" |
0 commit comments