Add contributor and fix n_sets calculation #762
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # ╔══════════════════════════════════════════════════════════════════════╗ | |
| # ║ Github Page – Optimized Build & Deploy ║ | |
| # ║ ║ | |
| # ║ Key optimizations over the original workflow: ║ | |
| # ║ 1. FREEZE CACHE FIX: babelquarto renders in a temp dir and only ║ | |
| # ║ copies _site back – the _freeze it creates is discarded. We ║ | |
| # ║ pre-render all non-frozen files first so that workspace _freeze ║ | |
| # ║ is fully populated before babelquarto copies it to its temp dir. ║ | |
| # ║ This turns a ~90 min render into ~20 min on subsequent runs. ║ | |
| # ║ 2. CHANGED-FILE DETECTION: Detects which QMDs changed and only ║ | |
| # ║ pre-renders those files, enabling fail-fast (catch errors in ║ | |
| # ║ ~2 min instead of ~90 min). ║ | |
| # ║ 3. CONDITIONAL JULIA SETUP: Skips the ~9 min Julia installation ║ | |
| # ║ when freeze already covers Julia files and none changed. ║ | |
| # ║ 4. SMART CONCURRENCY: Groups by PR number or branch so updated ║ | |
| # ║ pushes cancel stale in-progress builds. ║ | |
| # ║ 5. RESOURCE CONSERVATION: Failed builds still save their freeze ║ | |
| # ║ progress, so the next run picks up where it left off. ║ | |
| # ╚══════════════════════════════════════════════════════════════════════╝ | |
| on: | |
| push: | |
| branches: [main, master] | |
| paths-ignore: | |
| - '*.md' | |
| - 'LICENSE' | |
| - 'scripts/**' | |
| - '.github/scripts/**' | |
| - '.github/workflows/auto-translate.yml' | |
| - '.github/workflows/compress-images.yml' | |
| - '.github/workflows/generate-skills.yml' | |
| - '.github/workflows/skill-command.yml' | |
| - '.github/workflows/translate-command.yml' | |
| pull_request: | |
| branches: [main, master] | |
| paths-ignore: | |
| - '*.md' | |
| workflow_dispatch: | |
| inputs: | |
| force_full_rebuild: | |
| description: 'Force full rebuild (delete freeze cache and re-execute all files)' | |
| type: boolean | |
| default: false | |
| name: Github Page | |
| env: | |
| RUST_BACKTRACE: 1 | |
| jobs: | |
| build-deploy: | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 180 | |
| # Group by PR number for PRs, by branch ref for pushes. | |
| # New pushes/commits cancel the previous in-progress build in the same group. | |
| concurrency: | |
| group: quarto-build-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} | |
| cancel-in-progress: true | |
| env: | |
| GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} | |
| TMP_DIR: /mnt/TMP | |
| permissions: | |
| contents: write | |
| pages: write | |
| id-token: write | |
| steps: | |
| - name: Checkout repo | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| # ── Detect changed files ───────────────────────────────── | |
| # Determines which QMDs changed so we can: | |
| # (a) pre-render only those files (fail-fast) | |
| # (b) skip Julia/Python setup when unnecessary | |
| - name: Detect changed QMD files | |
| id: changes | |
| run: | | |
| if [ "${{ github.event_name }}" = "pull_request" ]; then | |
| BASE_SHA="${{ github.event.pull_request.base.sha }}" | |
| elif [ "${{ github.event_name }}" = "push" ]; then | |
| BASE_SHA="${{ github.event.before }}" | |
| else | |
| BASE_SHA="" | |
| fi | |
| # Handle first push, force push, or manual dispatch | |
| if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then | |
| echo "changed_qmds=" >> "$GITHUB_OUTPUT" | |
| echo "has_changes=false" >> "$GITHUB_OUTPUT" | |
| echo "need_julia=true" >> "$GITHUB_OUTPUT" | |
| echo "need_python=true" >> "$GITHUB_OUTPUT" | |
| echo "ℹ️ No base SHA available – treating as full build" | |
| exit 0 | |
| fi | |
| CHANGED_QMDS=$(git diff --name-only --diff-filter=ACMR "$BASE_SHA" HEAD -- '*.qmd' 2>/dev/null | tr '\n' ' ' || echo "") | |
| echo "changed_qmds=$CHANGED_QMDS" >> "$GITHUB_OUTPUT" | |
| if [ -n "$CHANGED_QMDS" ]; then | |
| echo "has_changes=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "has_changes=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| JULIA_CHANGED=$(echo "$CHANGED_QMDS" | grep -c 'Julia/' || true) | |
| PYTHON_CHANGED=$(echo "$CHANGED_QMDS" | grep -c 'Python/' || true) | |
| echo "need_julia=$( [ "$JULIA_CHANGED" -gt 0 ] && echo true || echo false )" >> "$GITHUB_OUTPUT" | |
| echo "need_python=$( [ "$PYTHON_CHANGED" -gt 0 ] && echo true || echo false )" >> "$GITHUB_OUTPUT" | |
| echo "📋 Changed QMDs: ${CHANGED_QMDS:-none}" | |
| echo " Julia changes: $JULIA_CHANGED | Python changes: $PYTHON_CHANGED" | |
| # ── Restore freeze cache (CRITICAL for performance) ────── | |
| # The freeze cache stores pre-computed knitr outputs for all QMD files. | |
| # It is populated by the "Pre-render all non-frozen QMD files" step | |
| # (which writes directly to the workspace _freeze directory), NOT by | |
| # babelquarto itself (babelquarto renders in a temp dir and never | |
| # syncs _freeze back to the workspace). | |
| # | |
| # Key strategy: | |
| # - Primary key includes commit SHA (unique per commit) | |
| # - run_attempt in the key ensures retries create a new cache entry | |
| # (cache entries are immutable). Retries still restore from the | |
| # previous attempt via the restore-keys prefix match. | |
| # - restore-keys prefix ensures we always get the latest cache | |
| # - A separate save step (with if: always()) preserves progress | |
| # even when the build fails partway through | |
| - name: Restore freeze cache | |
| id: freeze-restore | |
| if: inputs.force_full_rebuild != true | |
| uses: actions/cache/restore@v4 | |
| with: | |
| path: _freeze | |
| key: freeze-${{ runner.os }}-${{ github.sha }}-${{ github.run_attempt }} | |
| restore-keys: | | |
| freeze-${{ runner.os }}-${{ github.sha }}- | |
| freeze-${{ runner.os }}- | |
| - name: Clear freeze for forced full rebuild | |
| if: inputs.force_full_rebuild == true | |
| run: rm -rf _freeze | |
| - name: Report freeze cache status | |
| id: freeze-check | |
| run: | | |
| if [ -d "_freeze" ]; then | |
| FREEZE_COUNT=$(find _freeze -name "*.json" -type f 2>/dev/null | wc -l) | |
| echo "✅ Freeze cache available: $FREEZE_COUNT frozen output(s)" | |
| echo "has_freeze=true" >> "$GITHUB_OUTPUT" | |
| # Check per-language coverage | |
| JULIA_FROZEN=$(find _freeze -path "*/Julia/*" -name "*.json" 2>/dev/null | wc -l) | |
| PYTHON_FROZEN=$(find _freeze -path "*/Python/*" -name "*.json" 2>/dev/null | wc -l) | |
| echo "julia_frozen=$JULIA_FROZEN" >> "$GITHUB_OUTPUT" | |
| echo "python_frozen=$PYTHON_FROZEN" >> "$GITHUB_OUTPUT" | |
| echo " Julia frozen: $JULIA_FROZEN | Python frozen: $PYTHON_FROZEN" | |
| else | |
| echo "⚠️ No freeze cache – full execution required" | |
| echo "has_freeze=false" >> "$GITHUB_OUTPUT" | |
| echo "julia_frozen=0" >> "$GITHUB_OUTPUT" | |
| echo "python_frozen=0" >> "$GITHUB_OUTPUT" | |
| fi | |
| # ── Quarto Setup ──────────────────────────────────────── | |
| - name: Set up Quarto | |
| uses: quarto-dev/quarto-actions/setup@v2 | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| with: | |
| tinytex: true | |
| # ── R Setup ────────────────────────────────────────────── | |
| - uses: r-lib/actions/setup-r@v2 | |
| with: | |
| use-public-rspm: true | |
| - name: Cache R packages | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| ~/.local/share/renv | |
| ~/.cache/R/renv | |
| /usr/local/lib/R/site-library | |
| ~/R | |
| key: r-packages-${{ runner.os }}-${{ hashFiles('DESCRIPTION') }} | |
| restore-keys: | | |
| r-packages-${{ runner.os }}- | |
| - name: Install system dependencies | |
| run: | | |
| sudo apt-get update -y | |
| sudo apt-get install -y libcurl4-openssl-dev | |
| - uses: r-lib/actions/setup-r-dependencies@v2 | |
| env: | |
| PKG_SYSREQS: "true" | |
| # ── Python Setup ───────────────────────────────────────── | |
| - name: Set up Python | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.11' | |
| cache: 'pip' | |
| - name: Install Python dependencies | |
| run: | | |
| python -m pip install --upgrade pip | |
| if [ -f requirements.txt ]; then | |
| pip install -r requirements.txt | |
| fi | |
| # ── Julia Setup (conditional) ──────────────────────────── | |
| # Julia setup takes ~9 min. Skip it when: | |
| # - No Julia QMDs changed AND freeze already covers Julia files | |
| # Always install when: Julia files changed, no freeze, or force rebuild. | |
| - name: Set up Julia | |
| id: setup-julia | |
| if: >- | |
| steps.changes.outputs.need_julia == 'true' || | |
| steps.freeze-check.outputs.julia_frozen == '0' || | |
| inputs.force_full_rebuild == true | |
| uses: julia-actions/setup-julia@v2 | |
| with: | |
| version: '1' | |
| - name: Cache Julia packages | |
| if: steps.setup-julia.outcome == 'success' | |
| uses: actions/cache@v4 | |
| with: | |
| path: ~/.julia | |
| key: julia-packages-${{ runner.os }}-${{ hashFiles('Julia/Project.toml') }} | |
| restore-keys: | | |
| julia-packages-${{ runner.os }}- | |
| - name: Install Julia packages | |
| if: steps.setup-julia.outcome == 'success' | |
| run: | | |
| # Find the QuartoNotebookRunner version required by this Quarto installation. | |
| # Quarto bundles its own julia-engine Project.toml that pins an exact QNR version. | |
| JULIA_ENGINE_PROJ=$(find /opt/quarto -name "Project.toml" -path "*/julia-engine/*" 2>/dev/null | head -1) | |
| QNR_VERSION="" | |
| if [ -n "$JULIA_ENGINE_PROJ" ]; then | |
| QNR_VERSION=$(grep -oP 'QuartoNotebookRunner = "=([^"]+)"' "$JULIA_ENGINE_PROJ" \ | |
| | grep -oP '[0-9]+\.[0-9]+\.[0-9]+' || true) | |
| echo "Quarto requires QuartoNotebookRunner=${QNR_VERSION} (from ${JULIA_ENGINE_PROJ})" | |
| fi | |
| # Create a dedicated QNR environment. Setting QUARTO_JULIA_PROJECT to this | |
| # path tells Quarto to skip ensureQuartoNotebookRunnerEnvironment (which calls | |
| # Pkg.update() and needs network at render time) and simply verify QNR loads. | |
| QNR_ENV="${HOME}/.julia/environments/quarto-julia-env" | |
| mkdir -p "${QNR_ENV}" | |
| if [ -n "$QNR_VERSION" ]; then | |
| julia --project="${QNR_ENV}" -e " | |
| using Pkg | |
| Pkg.add(name=\"QuartoNotebookRunner\", version=\"${QNR_VERSION}\") | |
| Pkg.instantiate() | |
| using QuartoNotebookRunner | |
| println(\"QuartoNotebookRunner \$(pkgversion(QuartoNotebookRunner)) ready\") | |
| " | |
| else | |
| julia --project="${QNR_ENV}" -e ' | |
| using Pkg | |
| Pkg.add("QuartoNotebookRunner") | |
| Pkg.instantiate() | |
| using QuartoNotebookRunner | |
| println("QuartoNotebookRunner $(pkgversion(QuartoNotebookRunner)) ready") | |
| ' | |
| fi | |
| # Export so the render step can pick it up. | |
| echo "QUARTO_JULIA_PROJECT=${QNR_ENV}" >> "$GITHUB_ENV" | |
| # Pre-instantiate the tutorial project environment (Julia/Project.toml). | |
| # QNR workers use JULIA_PROJECT=@. and cd into the notebook directory, | |
| # so they automatically pick up Julia/Project.toml for CairoMakie etc. | |
| julia --project=Julia -e ' | |
| using Pkg | |
| Pkg.resolve() | |
| Pkg.instantiate() | |
| using CairoMakie, DataFrames, Statistics | |
| println("Tutorial packages ready") | |
| ' | |
| # ── Dependency Scanning ────────────────────────────────── | |
| - name: Scan and update dependencies | |
| run: | | |
| Rscript update-dependencies.R | |
| python manage-dependencies.py --update | |
| - name: Commit updated dependency files | |
| if: github.event_name != 'pull_request' | |
| run: | | |
| git config --local user.name "$GITHUB_ACTOR" | |
| git config --local user.email "$GITHUB_ACTOR@users.noreply.github.com" | |
| git add DESCRIPTION requirements.txt Julia/Project.toml | |
| git commit -m 'chore: update dependencies' || echo "No changes to commit" | |
| git push origin || echo "No changes to push" | |
| # ── Render ─────────────────────────────────────────────── | |
| - name: Create temporary directory | |
| run: | | |
| sudo mkdir -p ${{ env.TMP_DIR }} | |
| sudo chown -R $USER:$USER ${{ env.TMP_DIR }} | |
| # Pre-render Julia notebooks BEFORE the R-heavy main render to avoid OOM. | |
| # babelquarto loads R + hundreds of packages (~1.5 GB); adding CairoMakie | |
| # (~1-2 GB peak) would exceed the runner's RAM limit. | |
| - name: Pre-render Julia notebooks | |
| if: steps.setup-julia.outcome == 'success' | |
| env: | |
| QUARTO_JULIA_PROJECT: ${{ env.QUARTO_JULIA_PROJECT }} | |
| TMPDIR: ${{ env.TMP_DIR }} | |
| TMP: ${{ env.TMP_DIR }} | |
| TEMP: ${{ env.TMP_DIR }} | |
| run: | | |
| echo "Pre-rendering Julia tutorials (no R process = lower peak memory)..." | |
| failed=0 | |
| for qmd in Julia/*.qmd; do | |
| echo " → $qmd" | |
| if quarto render "$qmd" 2>&1; then | |
| echo " ✓ OK: $qmd" | |
| else | |
| echo " ✗ Failed: $qmd" | |
| failed=1 | |
| fi | |
| done | |
| julia_log="${HOME}/.cache/quarto/julia/julia_server_log.txt" | |
| if [ -f "$julia_log" ]; then | |
| echo "" | |
| echo "=== QNR server log ===" | |
| tail -50 "$julia_log" | |
| echo "======================" | |
| fi | |
| [ $failed -eq 0 ] || exit 1 | |
| echo "All Julia notebooks pre-rendered. Freeze cache populated." | |
| # Pre-render ONLY the changed QMD files for early failure detection. | |
| # If a changed file has a syntax error or broken dependency, this step | |
| # fails in ~2 min instead of waiting ~90 min for the full site render. | |
| # Successfully rendered files populate the freeze cache for the next step. | |
| - name: Pre-render changed QMD files (fail-fast) | |
| if: steps.changes.outputs.has_changes == 'true' | |
| env: | |
| CHANGED_QMDS: ${{ steps.changes.outputs.changed_qmds }} | |
| TMPDIR: ${{ env.TMP_DIR }} | |
| TMP: ${{ env.TMP_DIR }} | |
| TEMP: ${{ env.TMP_DIR }} | |
| QUARTO_JULIA_PROJECT: ${{ env.QUARTO_JULIA_PROJECT }} | |
| run: | | |
| echo "Pre-rendering changed files for early failure detection..." | |
| for qmd in $CHANGED_QMDS; do | |
| # Julia files are pre-rendered separately (OOM prevention) | |
| if [[ "$qmd" == Julia/* ]]; then continue; fi | |
| if [ ! -f "$qmd" ]; then continue; fi | |
| echo " → $qmd" | |
| quarto render "$qmd" 2>&1 || { echo "❌ FAILED: $qmd"; exit 1; } | |
| echo " ✓ OK: $qmd" | |
| # Also render the bilingual counterpart so freeze covers both | |
| if [[ "$qmd" == *.zh.qmd ]]; then | |
| counterpart="${qmd%.zh.qmd}.qmd" | |
| else | |
| counterpart="${qmd%.qmd}.zh.qmd" | |
| fi | |
| if [ -f "$counterpart" ] && ! echo "$CHANGED_QMDS" | grep -qF "$counterpart"; then | |
| echo " → $counterpart (bilingual counterpart)" | |
| quarto render "$counterpart" 2>&1 || { echo "❌ FAILED: $counterpart"; exit 1; } | |
| echo " ✓ OK: $counterpart" | |
| fi | |
| done | |
| echo "✅ All changed files pre-rendered successfully." | |
| # ── Pre-render ALL non-frozen files (freeze-cache fix) ───── | |
| # ROOT CAUSE: babelquarto::render_website() renders in a temp dir | |
| # (withr::local_tempdir()), creates _freeze there, copies only _site | |
| # back to the workspace, and then deletes the temp dir. The workspace | |
| # _freeze is never updated, so the save step finds only the few | |
| # entries from the changed-file pre-render step above (e.g. 6 Julia | |
| # entries), not the 500+ entries from the full render. | |
| # | |
| # FIX: pre-render every non-frozen .qmd file here (workspace _freeze | |
| # gets populated), then babelquarto copies the full _freeze into its | |
| # temp dir and uses it – skipping all code execution and only running | |
| # pandoc, cutting the render from ~90 min down to ~20 min. | |
| # | |
| # Timing estimates: | |
| # First run (cold cache): pre-render all ~60–80 min + | |
| # babelquarto (pandoc only) ~20 min + | |
| # setup ~15 min ≈ 95–115 min total | |
| # Subsequent runs (warm): pre-render 0 files + babelquarto ~20 min | |
| # + setup ~15 min ≈ 35 min total | |
| # Timeout is 180 min to give first-run cold-cache builds a safe margin. | |
| - name: Pre-render all non-frozen QMD files | |
| env: | |
| TMPDIR: ${{ env.TMP_DIR }} | |
| TMP: ${{ env.TMP_DIR }} | |
| TEMP: ${{ env.TMP_DIR }} | |
| QUARTO_JULIA_PROJECT: ${{ env.QUARTO_JULIA_PROJECT }} | |
| run: | | |
| echo "Pre-rendering non-frozen QMD files to populate workspace _freeze..." | |
| RENDERED=0 | |
| SKIPPED=0 | |
| FAILED=0 | |
| while IFS= read -r -d '' qmd; do | |
| rel="${qmd#./}" | |
| # Julia files are handled by the dedicated Julia pre-render step | |
| [[ "$rel" == Julia/* ]] && { SKIPPED=$((SKIPPED+1)); continue; } | |
| # If a _freeze entry already exists for this file, skip it. | |
| # Quarto mirrors the source path: _freeze/<path-without-ext>/ | |
| freeze_dir="_freeze/${rel%.qmd}" | |
| if [ -d "$freeze_dir" ]; then | |
| SKIPPED=$((SKIPPED+1)) | |
| continue | |
| fi | |
| echo " → $rel" | |
| if quarto render "$qmd" 2>&1; then | |
| RENDERED=$((RENDERED+1)) | |
| else | |
| echo " ⚠️ Warning: failed to render $rel (non-fatal)" | |
| FAILED=$((FAILED+1)) | |
| fi | |
| done < <(find . -name "*.qmd" \ | |
| -not -path "./_freeze/*" \ | |
| -not -path "./_site/*" \ | |
| -not -path "./.quarto/*" \ | |
| -print0 | sort -z) | |
| echo "" | |
| echo "📊 Pre-render summary: $RENDERED rendered, $SKIPPED skipped (frozen/Julia), $FAILED failed" | |
| [ $FAILED -gt 0 ] && echo "::warning::$FAILED QMD file(s) failed to pre-render" | |
| # Count JSON outputs for a richer diagnostic (one per frozen code chunk) | |
| TOTAL_FROZEN=$(find _freeze -name "*.json" -type f 2>/dev/null | wc -l) | |
| echo "❄️ Freeze cache now has $TOTAL_FROZEN frozen output(s) (JSON entries)" | |
| - name: Render website with Babelquarto | |
| env: | |
| TMPDIR: ${{ env.TMP_DIR }} | |
| TMP: ${{ env.TMP_DIR }} | |
| TEMP: ${{ env.TMP_DIR }} | |
| R_COMPILE_PKGS: 0 | |
| QUARTO_JULIA_PROJECT: ${{ env.QUARTO_JULIA_PROJECT }} | |
| run: Rscript -e 'babelquarto::render_website()' | |
| - name: Generate unified skill ZIP package | |
| run: | | |
| mkdir -p _site/ | |
| # Stage files with correct names for the zip | |
| SKILL_TMP=$(mktemp -d) | |
| cp SKILL.md "${SKILL_TMP}/SKILL.md" | |
| cp files/gallery_data.csv "${SKILL_TMP}/gallery_data.csv" | |
| cp files/gallery_data_zh.csv "${SKILL_TMP}/gallery_data_zh.csv" | |
| cp files/bizard-skill-readme.md "${SKILL_TMP}/README.md" | |
| (cd "${SKILL_TMP}" && zip -r - SKILL.md gallery_data.csv gallery_data_zh.csv README.md) > _site/bizard-skill.zip | |
| rm -rf "${SKILL_TMP}" | |
| echo "✅ Created _site/bizard-skill.zip ($(du -h _site/bizard-skill.zip | cut -f1))" | |
| # ── Save freeze cache (ALWAYS – even on failure) ───────── | |
| # This is the key to reducing wasted work across consecutive | |
| # failed builds. Even if the render fails on file #400, | |
| # files #1-#399 have their freeze saved for the next run. | |
| - name: Save freeze cache | |
| if: always() | |
| uses: actions/cache/save@v4 | |
| with: | |
| path: _freeze | |
| key: freeze-${{ runner.os }}-${{ github.sha }}-${{ github.run_attempt }} | |
| # ── Deploy ─────────────────────────────────────────────── | |
| - name: Upload Quarto artifacts | |
| if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' || github.event_name == 'pull_request' | |
| uses: actions/upload-pages-artifact@v3 | |
| with: | |
| path: "_site" | |
| - name: Deploy to GitHub Pages | |
| if: github.event_name != 'pull_request' | |
| uses: quarto-dev/quarto-actions/publish@v2 | |
| with: | |
| target: gh-pages | |
| render: false | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Cleanup temporary directory | |
| if: always() | |
| run: | | |
| TMP_PATH="${{ env.TMP_DIR }}" | |
| if [ -n "$TMP_PATH" ] && [[ "$TMP_PATH" =~ ^/mnt/TMP ]] && [ -d "$TMP_PATH" ]; then | |
| sudo rm -rf "$TMP_PATH" || echo "Warning: cleanup of $TMP_PATH failed (non-critical)" | |
| echo "Removed $TMP_PATH directory" | |
| else | |
| echo "Skipping cleanup - validation failed (path: $TMP_PATH)" | |
| fi |