Skip to content

📚 Book · 👁️ Preview (Dev) #644

📚 Book · 👁️ Preview (Dev)

📚 Book · 👁️ Preview (Dev) #644

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/"