📚 Book · 👁️ Preview (Dev) #644
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
| name: '📚 Book · 👁️ Preview (Dev)' | |
| # ============================================================================= | |
| # 👁️ Preview (Dev) — Deploy dev build artifacts to staging site | |
| # ============================================================================= | |
| # | |
| # Downloads HTML/PDF/EPUB artifacts from a successful Validate run and deploys | |
| # them to the dev preview site via SSH push to the target repository. | |
| # | |
| # Flow: | |
| # 1. RESOLVE_RUN — Find the correct Validate workflow run (auto or manual) | |
| # 2. DOWNLOAD — Pull Vol1 + Vol2 artifacts (HTML, PDF, EPUB) | |
| # 3. ASSEMBLE_SITE — Build preview site with volume chooser and downloads | |
| # 4. DEPLOY — Push to dev preview repo via SSH deploy key | |
| # | |
| # Triggers: | |
| # - workflow_run: on successful completion of Validate (Dev) | |
| # - workflow_dispatch: manual deploy of a specific commit or run ID | |
| # | |
| # Deploys to: Dev preview site (via SSH to DEV_REPO) | |
| # Secrets: GITHUB_TOKEN, SSH_DEPLOY_KEY | |
| # Vars: BOOK_TOOLS, DEV_REPO, DEV_REPO_URL, | |
| # VOL1_DEPLOY_PATH (top-level dir for Vol I, mirrors live), | |
| # VOL2_DEPLOY_PATH (top-level dir for Vol II, mirrors live) | |
| # | |
| # Related: | |
| # - book-validate-dev.yml — Produces the build artifacts this workflow deploys | |
| # - book-publish-live.yml — Production equivalent (deploys to gh-pages) | |
| # | |
| # ============================================================================= | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| commit_sha: | |
| description: 'Optional: Specific commit SHA to deploy. Deploys the latest successful `dev` build if empty. IMPORTANT: Select the `dev` branch before running.' | |
| required: false | |
| type: string | |
| run_id: | |
| description: 'Optional: Specific validate workflow run ID to pull artifacts from (bypasses success check).' | |
| required: false | |
| type: string | |
| workflow_run: | |
| workflows: ["📚 Book · ✅ Validate (Dev)"] | |
| types: | |
| - completed | |
| concurrency: | |
| group: deploy-preview-${{ github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| deploy: | |
| name: '📺 Deploy to GitHub Pages (Dev Preview)' | |
| if: (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'dev') || github.event_name == 'workflow_dispatch' | |
| runs-on: ubuntu-latest | |
| # Deploys dev branch to: https://harvard-edge.github.io/cs249r_book_dev/ | |
| permissions: | |
| contents: write # Allow write access to repository | |
| pages: write | |
| actions: read # Allow reading of workflow runs | |
| steps: | |
| - name: ⬇️ Get workflow run ID | |
| id: run_info | |
| run: | | |
| if [[ "${{ github.event_name }}" == "workflow_run" ]]; then | |
| echo "run_id=${{ github.event.workflow_run.id }}" >> $GITHUB_OUTPUT | |
| echo "head_sha=${{ github.event.workflow_run.head_sha }}" >> $GITHUB_OUTPUT | |
| exit 0 | |
| fi | |
| # If a specific run_id is provided, use it directly (bypasses success check) | |
| RUN_ID_INPUT=$(echo "${{ github.event.inputs.run_id }}" | xargs) | |
| if [[ -n "$RUN_ID_INPUT" ]]; then | |
| echo "📌 Using provided run ID: $RUN_ID_INPUT" | |
| echo "run_id=$RUN_ID_INPUT" >> $GITHUB_OUTPUT | |
| RUN_SHA=$(gh api repos/${{ github.repository }}/actions/runs/${RUN_ID_INPUT} --jq '.head_sha') | |
| echo "head_sha=$RUN_SHA" >> $GITHUB_OUTPUT | |
| exit 0 | |
| fi | |
| COMMIT_SHA=$(echo "${{ github.event.inputs.commit_sha }}" | xargs) | |
| API_URL="repos/${{ github.repository }}/actions/workflows/book-validate-dev.yml/runs" | |
| if [[ -n "$COMMIT_SHA" ]]; then | |
| echo "🔎 Searching for successful 'Validate Dev' run for commit starting with: $COMMIT_SHA" | |
| JQ_QUERY=".workflow_runs[] | select((.head_sha | startswith(\"$COMMIT_SHA\")) and .status == \"completed\" and .conclusion == \"success\") | .id" | |
| else | |
| echo "🔎 No commit SHA provided. Searching for the latest successful 'Validate Dev' run on 'dev' branch." | |
| JQ_QUERY=".workflow_runs[] | select(.head_branch == \"dev\" and .status == \"completed\" and .conclusion == \"success\") | .id" | |
| fi | |
| latest_run_id=$(gh api "$API_URL" --jq "$JQ_QUERY" | head -n 1) | |
| if [ -z "$latest_run_id" ]; then | |
| if [[ -n "$COMMIT_SHA" ]]; then | |
| echo "::error::Could not find a successful 'Validate Dev' run for commit ${COMMIT_SHA}." | |
| else | |
| echo "::error::Could not find any successful 'Validate Dev' run on the 'dev' branch." | |
| fi | |
| exit 1 | |
| fi | |
| echo "Found run ID: ${latest_run_id}" | |
| echo "run_id=${latest_run_id}" >> $GITHUB_OUTPUT | |
| latest_run_sha=$(gh api repos/${{ github.repository }}/actions/runs/${latest_run_id} --jq '.head_sha') | |
| echo "head_sha=${latest_run_sha}" >> $GITHUB_OUTPUT | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: 📥 Download Vol1 HTML Artifact | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: dev-html-vol1-linux | |
| run-id: ${{ steps.run_info.outputs.run_id }} | |
| path: ./html-vol1-artifact | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| - name: 📥 Download Vol2 HTML Artifact | |
| uses: actions/download-artifact@v8 | |
| with: | |
| name: dev-html-vol2-linux | |
| run-id: ${{ steps.run_info.outputs.run_id }} | |
| path: ./html-vol2-artifact | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| - name: 📥 Download Vol1 PDF Artifact | |
| uses: actions/download-artifact@v8 | |
| continue-on-error: true | |
| with: | |
| name: dev-pdf-vol1-linux | |
| run-id: ${{ steps.run_info.outputs.run_id }} | |
| path: ./pdf-vol1-artifact | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| - name: 📥 Download Vol2 PDF Artifact | |
| uses: actions/download-artifact@v8 | |
| continue-on-error: true | |
| with: | |
| name: dev-pdf-vol2-linux | |
| run-id: ${{ steps.run_info.outputs.run_id }} | |
| path: ./pdf-vol2-artifact | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| - name: 📥 Download Vol1 EPUB Artifact | |
| uses: actions/download-artifact@v8 | |
| continue-on-error: true | |
| with: | |
| name: dev-epub-vol1-linux | |
| run-id: ${{ steps.run_info.outputs.run_id }} | |
| path: ./epub-vol1-artifact | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| - name: 📥 Download Vol2 EPUB Artifact | |
| uses: actions/download-artifact@v8 | |
| continue-on-error: true | |
| with: | |
| name: dev-epub-vol2-linux | |
| run-id: ${{ steps.run_info.outputs.run_id }} | |
| path: ./epub-vol2-artifact | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| - name: 📦 Build volume preview site | |
| run: | | |
| echo "📦 Preparing volume preview site..." | |
| mkdir -p ./preview-site/vol1 ./preview-site/vol2 | |
| echo "📋 Vol1 artifact contents:" | |
| ls -la ./html-vol1-artifact/ 2>/dev/null || echo " (empty or missing)" | |
| echo "📋 Vol2 artifact contents:" | |
| ls -la ./html-vol2-artifact/ 2>/dev/null || echo " (empty or missing)" | |
| # Use rsync-style copy that handles both flat and nested artifact layouts | |
| cp -r ./html-vol1-artifact/. ./preview-site/vol1/ | |
| cp -r ./html-vol2-artifact/. ./preview-site/vol2/ | |
| mkdir -p ./preview-site/vol1/assets/downloads ./preview-site/vol2/assets/downloads | |
| # Copy PDF/EPUB downloads by exact name (artifact dirs contain many files; | |
| # a wildcard find can pick up a tiny figure PDF instead of the book) | |
| VOL1_PDF=$(find ./pdf-vol1-artifact -name "Machine-Learning-Systems-Vol1.pdf" -type f 2>/dev/null | head -1) | |
| [ -n "$VOL1_PDF" ] && cp "$VOL1_PDF" "./preview-site/vol1/assets/downloads/Machine-Learning-Systems-Vol1.pdf" || true | |
| VOL1_EPUB=$(find ./epub-vol1-artifact -name "Machine-Learning-Systems-Vol1.epub" -type f 2>/dev/null | head -1) | |
| [ -n "$VOL1_EPUB" ] && cp "$VOL1_EPUB" "./preview-site/vol1/assets/downloads/Machine-Learning-Systems-Vol1.epub" || true | |
| VOL2_PDF=$(find ./pdf-vol2-artifact -name "Machine-Learning-Systems-Vol2.pdf" -type f 2>/dev/null | head -1) | |
| [ -n "$VOL2_PDF" ] && cp "$VOL2_PDF" "./preview-site/vol2/assets/downloads/Machine-Learning-Systems-Vol2.pdf" || true | |
| VOL2_EPUB=$(find ./epub-vol2-artifact -name "Machine-Learning-Systems-Vol2.epub" -type f 2>/dev/null | head -1) | |
| [ -n "$VOL2_EPUB" ] && cp "$VOL2_EPUB" "./preview-site/vol2/assets/downloads/Machine-Learning-Systems-Vol2.epub" || true | |
| - name: 📝 Create .nojekyll file | |
| run: touch ./preview-site/.nojekyll | |
| - name: 📦 Inspect final site contents | |
| run: | | |
| echo "::group::Site Contents" | |
| ls -la ./preview-site | |
| echo "::endgroup::" | |
| echo "::group::Vol1 Downloads" | |
| if [ -d "./preview-site/vol1/assets/downloads" ]; then | |
| ls -lh ./preview-site/vol1/assets/downloads | |
| else | |
| echo "⚠️ No vol1/assets/downloads directory" | |
| fi | |
| echo "::endgroup::" | |
| echo "::group::Vol2 Downloads" | |
| if [ -d "./preview-site/vol2/assets/downloads" ]; then | |
| ls -lh ./preview-site/vol2/assets/downloads | |
| else | |
| echo "⚠️ No vol2/assets/downloads directory" | |
| fi | |
| echo "::endgroup::" | |
| # Sanity check: PDFs should be >1MB (a figure PDF would be <100KB) | |
| for vol in vol1 vol2; do | |
| PDF="./preview-site/$vol/assets/downloads/Machine-Learning-Systems-$(echo $vol | sed 's/vol/Vol/').pdf" | |
| if [ -f "$PDF" ]; then | |
| SIZE=$(stat -c%s "$PDF" 2>/dev/null || stat -f%z "$PDF" 2>/dev/null) | |
| if [ "$SIZE" -lt 1000000 ]; then | |
| echo "::error::$vol PDF is only ${SIZE} bytes — likely a figure, not the book!" | |
| else | |
| echo "✅ $vol PDF looks correct: $(du -h "$PDF" | cut -f1)" | |
| fi | |
| fi | |
| done | |
| - name: ⬇️ Checkout repository (for scripts) | |
| uses: actions/checkout@v6 | |
| with: | |
| ref: dev | |
| path: ./repo-scripts | |
| sparse-checkout: | | |
| ${{ vars.BOOK_TOOLS }}/scripts/publish/modify_dev_announcement.py | |
| .github/scripts/visual_smoke.py | |
| .github/scripts/rewrite-dev-urls.sh | |
| .github/scripts/flatten-vol-urls.sh | |
| sparse-checkout-cone-mode: false | |
| - name: 🔧 Flatten volume URL structure | |
| # Moves contents/vol{N}/* to the site root so preview URLs are | |
| # devsite/vol{N}/{chapter}/{chapter}.html (not double-nested). | |
| # Must run before rewrite-dev-urls so depth calculations are correct. | |
| run: | | |
| if [ -d "./preview-site/vol1" ]; then | |
| bash ./repo-scripts/.github/scripts/flatten-vol-urls.sh ./preview-site/vol1 vol1 | |
| fi | |
| if [ -d "./preview-site/vol2" ]; then | |
| bash ./repo-scripts/.github/scripts/flatten-vol-urls.sh ./preview-site/vol2 vol2 | |
| fi | |
| - name: 🔗 Rewrite URLs for dev site | |
| run: | | |
| bash ./repo-scripts/.github/scripts/rewrite-dev-urls.sh vol1 ./preview-site/vol1 --live-external | |
| bash ./repo-scripts/.github/scripts/rewrite-dev-urls.sh vol2 ./preview-site/vol2 --live-external | |
| - name: 🧾 Create dev release manifests | |
| env: | |
| BUILD_SHA: ${{ steps.run_info.outputs.head_sha }} | |
| run: | | |
| python3 - <<'PY' | |
| import hashlib | |
| import json | |
| import os | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| build_sha = os.environ["BUILD_SHA"] | |
| build_date = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") | |
| for volume in ("vol1", "vol2"): | |
| site_dir = Path("preview-site") / volume | |
| if not site_dir.is_dir(): | |
| continue | |
| manifest = { | |
| "buildDate": build_date, | |
| "project": f"book-{volume}-dev", | |
| "releaseHash": hashlib.sha256(build_sha.encode()).hexdigest(), | |
| "releaseId": f"dev-{build_sha[:8]}", | |
| "schemaVersion": "1", | |
| "tier": "dev", | |
| } | |
| (site_dir / "release-manifest.json").write_text( | |
| json.dumps(manifest, indent=2) + "\n", encoding="utf-8" | |
| ) | |
| PY | |
| - name: 🔧 Modify announcement for dev preview | |
| run: | | |
| echo "🔧 Modifying announcement banner for development preview..." | |
| # Get commit info for display | |
| COMMIT_HASH="${{ steps.run_info.outputs.head_sha }}" | |
| COMMIT_SHORT="${COMMIT_HASH:0:8}" | |
| echo "📝 Adding dev preview banner with commit info: $COMMIT_SHORT" | |
| python3 ./repo-scripts/${{ vars.BOOK_TOOLS }}/scripts/publish/modify_dev_announcement.py \ | |
| ./preview-site \ | |
| --verbose \ | |
| --commit-hash "$COMMIT_HASH" \ | |
| --commit-short "$COMMIT_SHORT" | |
| - name: 🎭 Install browser for book layout smoke test | |
| run: | | |
| pip install playwright | |
| playwright install --with-deps chromium | |
| - name: 👁️ Verify rendered book layout before deployment | |
| run: | | |
| python3 ./repo-scripts/.github/scripts/visual_smoke.py \ | |
| --build-dir ./preview-site \ | |
| --site book-dev \ | |
| --pages \ | |
| /vol1/index.html \ | |
| /vol1/introduction/introduction.html \ | |
| /vol1/training/training.html \ | |
| /vol2/index.html \ | |
| --scan-all-book-pages \ | |
| --report-dir ./book-visual-smoke-report | |
| - name: 📤 Upload book layout smoke report | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: book-dev-visual-smoke-${{ steps.run_info.outputs.head_sha }} | |
| path: ./book-visual-smoke-report | |
| if-no-files-found: ignore | |
| retention-days: 14 | |
| - name: 🚀 Deploy to Dev Site via SSH | |
| env: | |
| SSH_DEPLOY_KEY: ${{ secrets.SSH_DEPLOY_KEY }} | |
| run: | | |
| echo "🔐 Starting ssh-agent..." | |
| eval "$(ssh-agent -s)" | |
| echo "$SSH_DEPLOY_KEY" | tr -d '\r' | ssh-add - > /dev/null | |
| # Add github.com to known hosts to avoid prompt. Port 22 intermittently | |
| # refuses connections from runners, so trust the 443 endpoint too and | |
| # keep it available as a fallback rather than losing a whole build. | |
| mkdir -p ~/.ssh | |
| ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null || true | |
| ssh-keyscan -p 443 ssh.github.com >> ~/.ssh/known_hosts | |
| echo "🔧 Configuring git..." | |
| git config --global user.email "actions@github.com" | |
| git config --global user.name "GitHub Actions" | |
| echo "🔄 Cloning target repository (${{ vars.DEV_REPO }})..." | |
| clone_target() { | |
| git clone --depth=1 "${{ vars.DEV_REPO_URL }}" target-repo | |
| } | |
| cloned=false | |
| for attempt in 1 2 3; do | |
| if clone_target; then | |
| cloned=true | |
| break | |
| fi | |
| rm -rf target-repo | |
| echo "⚠️ Clone attempt ${attempt} failed." | |
| if [ "$attempt" -eq 2 ]; then | |
| echo "🔁 Falling back to ssh.github.com:443 for the final attempt." | |
| printf 'Host github.com\n Hostname ssh.github.com\n Port 443\n User git\n' >> ~/.ssh/config | |
| fi | |
| sleep $((attempt * 10)) | |
| done | |
| if [ "$cloned" != true ]; then | |
| echo "❌ Could not reach the dev site repository over SSH after 3 attempts." | |
| exit 1 | |
| fi | |
| echo "🔄 Entering target repository..." | |
| cd target-repo | |
| # Per-volume top-level deploy paths. Mirrors book-publish-live.yml so | |
| # /vol1/ and /vol2/ resolve identically on dev preview and live. | |
| VOL1_PATH="${{ vars.VOL1_DEPLOY_PATH }}" | |
| VOL2_PATH="${{ vars.VOL2_DEPLOY_PATH }}" | |
| if [ -z "$VOL1_PATH" ] || [ -z "$VOL2_PATH" ]; then | |
| echo "❌ vars.VOL1_DEPLOY_PATH or vars.VOL2_DEPLOY_PATH is unset — refusing to run destructive rm with empty path." | |
| exit 1 | |
| fi | |
| echo "🧹 Cleaning /$VOL1_PATH/ and /$VOL2_PATH/ subdirectories (preserving other subsites)..." | |
| rm -rf "$VOL1_PATH" "$VOL2_PATH" | |
| # Also clean up the legacy /book/ wrapper from the previous nested layout. | |
| # Keeping it would leave zombie content at harvard-edge.github.io/cs249r_book_dev/book/. | |
| rm -rf book | |
| mkdir -p "$VOL1_PATH" "$VOL2_PATH" | |
| echo "🚚 Copying Vol1 content to /$VOL1_PATH/..." | |
| cp -r "${{ github.workspace }}/preview-site/vol1/." "$VOL1_PATH/" | |
| echo "🚚 Copying Vol2 content to /$VOL2_PATH/..." | |
| cp -r "${{ github.workspace }}/preview-site/vol2/." "$VOL2_PATH/" | |
| touch .nojekyll | |
| echo "🔍 Validating deployment content..." | |
| if [ ! -f "$VOL1_PATH/index.html" ]; then | |
| echo "❌ CRITICAL: $VOL1_PATH/index.html is missing after copy. Aborting deployment." | |
| exit 1 | |
| fi | |
| if [ ! -f "$VOL2_PATH/index.html" ]; then | |
| echo "❌ CRITICAL: $VOL2_PATH/index.html is missing after copy. Aborting deployment." | |
| exit 1 | |
| fi | |
| echo "📦 Repository structure:" | |
| ls -la | |
| echo "" | |
| echo "📦 /$VOL1_PATH/ contents:" | |
| ls -la "$VOL1_PATH/" | head -10 | |
| echo "" | |
| echo "📦 /$VOL2_PATH/ contents:" | |
| ls -la "$VOL2_PATH/" | head -10 | |
| echo "📦 Committing and pushing changes..." | |
| git add . | |
| git commit -m "🚀 Deploy book dev preview to /$VOL1_PATH/ + /$VOL2_PATH/ from ${{ steps.run_info.outputs.head_sha }}" --allow-empty || echo "🟡 Nothing to commit" | |
| # Retry push with rebase to handle concurrent deploys | |
| for i in 1 2 3; do | |
| if git push origin main 2>/dev/null; then | |
| echo "✅ Push succeeded on attempt $i" | |
| break | |
| fi | |
| echo "⚠️ Push failed (attempt $i/3), pulling with rebase..." | |
| git pull --rebase origin main || true | |
| done | |
| # DEV_REPO contains org/repo format, extract just repo name for GitHub Pages URL | |
| REPO_NAME="${{ vars.DEV_REPO }}" | |
| REPO_NAME="${REPO_NAME##*/}" | |
| echo "✅ Volumes deployed to:" | |
| echo " https://harvard-edge.github.io/${REPO_NAME}/$VOL1_PATH/" | |
| echo " https://harvard-edge.github.io/${REPO_NAME}/$VOL2_PATH/" |