diff --git a/.github/scripts/diff-book-pdf.sh b/.github/scripts/diff-book-pdf.sh new file mode 100755 index 0000000..8ab36a3 --- /dev/null +++ b/.github/scripts/diff-book-pdf.sh @@ -0,0 +1,142 @@ +#!/bin/bash +# +# Generate a PDF diff comment and per-page montage assets for the book PR. +# +# Adapted from stamped-principles/stamped-paper's gh-pages-diff-pdf.sh; the +# main difference is that the base PDF is a local file (built in the same +# CI job) rather than fetched from a deployed URL. +# +# Usage: diff-book-pdf.sh \ +# +# +# Inputs: +# pr-pdf path to the PR-built book PDF +# base-pdf path to the base-branch-built book PDF (may be missing) +# preview-branch name of the per-PR preview branch (e.g. gh-pages-pr-42) +# repo GitHub repository (owner/name) +# server GitHub server URL (https://github.com) +# head-sha full SHA of the PR head commit +# base-ref name of the base branch (e.g. main) — used in comment text +# +# Outputs in the working directory: +# pr-comment.md markdown body for the PR comment +# diff.pdf visual diff PDF (only when differences exist) +# diff-page-NN.png per-page 3-panel montages for changed pages + +set -euo pipefail + +pr_pdf="$1" +base_pdf="$2" +preview_branch="$3" +repo="$4" +server="$5" +head_sha="$6" +base_ref="${7:-main}" + +raw_base="https://raw.githubusercontent.com/${repo}/${preview_branch}" +view_url="${server}/${repo}/blob/${preview_branch}/book.pdf" +diff_view_url="${server}/${repo}/blob/${preview_branch}/diff.pdf" + +{ + echo "" + echo "### Book PDF" + echo "Built from ${head_sha}." + echo "**[View PDF](${view_url})** | **[Download](${raw_base}/book.pdf)**" +} > pr-comment.md + +if [ ! -f "$base_pdf" ]; then + echo "" >> pr-comment.md + echo "*Base PDF (\`${base_ref}\`) was not available — diff skipped.*" >> pr-comment.md + exit 0 +fi + +if diff-pdf --output-diff=diff.pdf --dpi=300 --channel-tolerance=0 -g \ + "$base_pdf" "$pr_pdf"; then + echo "" >> pr-comment.md + echo "No visual differences from \`${base_ref}\`." >> pr-comment.md + exit 0 +fi + +# Differences exist — build per-page montages. +mkdir -p diff-pages +pdftoppm -png -r 150 diff.pdf diff-pages/page +pdftoppm -png -r 150 "$base_pdf" diff-pages/base +pdftoppm -png -r 150 "$pr_pdf" diff-pages/pr + +mapfile -t diff_pngs < <(find diff-pages -name 'page-*.png' | sort -V) +mapfile -t base_pngs < <(find diff-pages -name 'base-*.png' | sort -V) +mapfile -t pr_pngs < <(find diff-pages -name 'pr-*.png' | sort -V) + +base_count=${#base_pngs[@]} +pr_count=${#pr_pngs[@]} +diff_count=${#diff_pngs[@]} +max_count=$((base_count > pr_count ? base_count : pr_count)) + +changed=0 +unchanged=0 +added=0 +removed=0 +page_details="" + +for ((i = 0; i < max_count; i++)); do + page_num=$((i + 1)) + padded=$(printf '%02d' "$page_num") + + if [ "$i" -lt "$base_count" ] && [ "$i" -lt "$pr_count" ]; then + if [ "$i" -lt "$diff_count" ]; then + pixels=$(compare -metric AE -fuzz 2% \ + "${base_pngs[$i]}" "${pr_pngs[$i]}" /dev/null 2>&1 || true) + + if [ "$pixels" = "0" ] || ! [[ "$pixels" =~ ^[0-9]+$ ]]; then + unchanged=$((unchanged + 1)) + page_details+="- Page ${page_num} — unchanged\n" + else + changed=$((changed + 1)) + compare -fuzz 2% -highlight-color '#FF000060' \ + "${base_pngs[$i]}" "${pr_pngs[$i]}" \ + -compose src "diff-pages/highlighted-${padded}.png" 2>/dev/null || true + montage_args=(-tile 3x1 -geometry '+4+4') + if ! montage \ + -font DejaVu-Sans \ + -label "${base_ref}" "${base_pngs[$i]}" \ + -label "PR" "${pr_pngs[$i]}" \ + -label "changes" "diff-pages/highlighted-${padded}.png" \ + "${montage_args[@]}" "diff-page-${padded}.png" 2>/dev/null; then + montage +label \ + "${base_pngs[$i]}" "${pr_pngs[$i]}" \ + "diff-pages/highlighted-${padded}.png" \ + "${montage_args[@]}" "diff-page-${padded}.png" + fi + diff_img_url="${raw_base}/diff-page-${padded}.png" + page_details+="
Page ${page_num} — changed\n\n" + page_details+="![page ${page_num} diff](${diff_img_url})\n\n" + page_details+="
\n" + fi + fi + elif [ "$i" -lt "$pr_count" ]; then + added=$((added + 1)) + page_details+="- Page ${page_num} — **new**\n" + else + removed=$((removed + 1)) + page_details+="- Page ${page_num} — **removed**\n" + fi +done + +parts=() +[ "$changed" -gt 0 ] && parts+=("${changed} changed") +[ "$unchanged" -gt 0 ] && parts+=("${unchanged} unchanged") +[ "$added" -gt 0 ] && parts+=("${added} added") +[ "$removed" -gt 0 ] && parts+=("${removed} removed") +summary="${pr_count} pages total: $(IFS=', '; echo "${parts[*]}")" +[ "$base_count" -ne "$pr_count" ] && summary+=" (was ${base_count})" + +{ + echo "" + echo "#### Diff vs \`${base_ref}\`" + echo "${summary}" + echo "**[View full diff PDF](${diff_view_url})**" + echo "" + printf '%b' "$page_details" +} >> pr-comment.md + +rm -rf diff-pages diff --git a/.github/workflows/build-pdf.yml b/.github/workflows/build-pdf.yml new file mode 100644 index 0000000..e470476 --- /dev/null +++ b/.github/workflows/build-pdf.yml @@ -0,0 +1,155 @@ +name: Build PDF and preview on PRs + +# Builds the book PDF on every pull request, builds the base-branch PDF +# for comparison, and pushes both plus a page-by-page visual diff to a +# per-PR preview branch so a reviewer can browse the rendered output +# from the PR conversation. +# +# Modeled on the stamped-principles/stamped-paper build-pdf.yml pattern, +# with two adaptations for this repo: +# 1. Base PDF is built locally (checked-out `main`), rather than +# fetched from a Pages URL — this repo does not publish the PDF. +# 2. mystmd's PDF export is pinned to plain_latex_book so we skip +# the current Typst export path (which trips on `**X**yz` bold +# markdown patterns in the sources). + +on: + pull_request: + branches: [main] + workflow_dispatch: + +concurrency: + group: build-pdf-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: write + pull-requests: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout PR head + uses: actions/checkout@v4 + with: + path: pr + + - name: Checkout base branch + if: github.event_name == 'pull_request' + uses: actions/checkout@v4 + with: + ref: ${{ github.base_ref }} + path: base + + - name: Install LaTeX + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + texlive-latex-base \ + texlive-latex-recommended \ + texlive-latex-extra \ + texlive-fonts-recommended \ + texlive-bibtex-extra \ + latexmk + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install mystmd + run: npm install -g mystmd + + - uses: astral-sh/setup-uv@v4 + + - name: Build PR PDF + working-directory: pr + run: uv run myst build --pdf --template plain_latex_book + + - name: Build base PDF + if: github.event_name == 'pull_request' + working-directory: base + run: uv run myst build --pdf --template plain_latex_book + + - name: Stage PDFs + run: | + cp pr/exports/book.pdf pr-book.pdf + if [ -f base/exports/book.pdf ]; then + cp base/exports/book.pdf base-book.pdf + fi + + - name: Generate PDF diff + if: github.event_name == 'pull_request' + id: diff + run: | + docker run --rm -v "$PWD:/work" -w /work \ + ghcr.io/stamped-principles/diff-pdf:latest \ + bash pr/.github/scripts/diff-book-pdf.sh \ + pr-book.pdf \ + base-book.pdf \ + "gh-pages-pr-${{ github.event.pull_request.number }}" \ + "${{ github.repository }}" \ + "${{ github.server_url }}" \ + "${{ github.event.pull_request.head.sha }}" \ + "${{ github.base_ref }}" + { + echo 'body<> "$GITHUB_OUTPUT" + + - name: Push PDFs and diffs to preview branch + if: github.event_name == 'pull_request' + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + working-directory: pr + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + BRANCH="gh-pages-pr-${PR_NUMBER}" + git checkout --orphan "$BRANCH" + git rm -rf --cached . + cp ../pr-book.pdf book.pdf + git add -f book.pdf + [ -f ../diff.pdf ] && cp ../diff.pdf diff.pdf && git add -f diff.pdf + for f in ../diff-page-*.png; do + [ -f "$f" ] && cp "$f" . && git add -f "$(basename "$f")" + done + git commit -m "PDF preview for PR #${PR_NUMBER} (${HEAD_SHA:0:7})" + git push -f origin "$BRANCH" + + - name: Upload PR PDF artifact + uses: actions/upload-artifact@v4 + with: + name: book-pdf + path: pr-book.pdf + retention-days: 14 + + - name: Upload diff artifacts + if: github.event_name == 'pull_request' && hashFiles('diff.pdf') != '' + uses: actions/upload-artifact@v4 + with: + name: book-pdf-diff + path: | + diff.pdf + diff-page-*.png + retention-days: 14 + + - name: Find existing PR comment + if: github.event_name == 'pull_request' + uses: peter-evans/find-comment@v3 + id: find-comment + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: 'github-actions[bot]' + body-includes: '' + + - name: Post or update PR comment + if: github.event_name == 'pull_request' + uses: peter-evans/create-or-update-comment@v4 + with: + comment-id: ${{ steps.find-comment.outputs.comment-id }} + issue-number: ${{ github.event.pull_request.number }} + body: ${{ steps.diff.outputs.body }} + edit-mode: replace diff --git a/.github/workflows/cleanup-pr-preview.yml b/.github/workflows/cleanup-pr-preview.yml new file mode 100644 index 0000000..1022cfd --- /dev/null +++ b/.github/workflows/cleanup-pr-preview.yml @@ -0,0 +1,19 @@ +name: Clean up PR preview branch + +on: + pull_request: + types: [closed] + +permissions: + contents: write + +jobs: + cleanup: + runs-on: ubuntu-latest + steps: + - name: Delete PR preview branch + env: + GH_TOKEN: ${{ github.token }} + run: | + BRANCH="gh-pages-pr-${{ github.event.pull_request.number }}" + gh api -X DELETE "repos/${{ github.repository }}/git/refs/heads/${BRANCH}" 2>/dev/null || true