mirror of
https://github.com/harvard-edge/cs249r_book.git
synced 2026-03-09 07:15:51 -05:00
Replace embedded subexpression interpolation in the dockerized PowerShell preflight checks with format strings so command source logging does not break script parsing.
912 lines
42 KiB
YAML
912 lines
42 KiB
YAML
name: '📚 Book · 🔨 Build (Container)'
|
|
|
|
# Concurrency disabled - allow unlimited parallel builds
|
|
|
|
# This workflow uses pre-built containers with all dependencies installed
|
|
# Matrix approach: 4 parallel jobs for maximum speed
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
build_linux:
|
|
description: '🐧 Build on Linux'
|
|
required: false
|
|
default: true
|
|
type: boolean
|
|
build_windows:
|
|
description: '🪟 Build on Windows'
|
|
required: false
|
|
default: true
|
|
type: boolean
|
|
build_html:
|
|
description: '📄 Build HTML format'
|
|
required: false
|
|
default: true
|
|
type: boolean
|
|
build_pdf:
|
|
description: '📑 Build PDF format'
|
|
required: false
|
|
default: true
|
|
type: boolean
|
|
build_epub:
|
|
description: '📚 Build EPUB format'
|
|
required: false
|
|
default: true
|
|
type: boolean
|
|
build_target:
|
|
description: '📦 Build target (vol1, vol2, all)'
|
|
required: false
|
|
type: choice
|
|
default: 'all'
|
|
options:
|
|
- vol1
|
|
- vol2
|
|
- all
|
|
target:
|
|
description: 'Target branch (dev/main)'
|
|
required: false
|
|
type: choice
|
|
default: 'dev'
|
|
options:
|
|
- dev
|
|
- main
|
|
container_registry:
|
|
description: 'Container registry (e.g., ghcr.io)'
|
|
required: false
|
|
type: string
|
|
default: 'ghcr.io'
|
|
container_tag:
|
|
description: 'Container tag (e.g., latest)'
|
|
required: false
|
|
type: string
|
|
default: 'latest'
|
|
workflow_call:
|
|
inputs:
|
|
build_linux:
|
|
required: false
|
|
type: boolean
|
|
default: true
|
|
build_windows:
|
|
required: false
|
|
type: boolean
|
|
default: true
|
|
build_html:
|
|
required: false
|
|
type: boolean
|
|
default: true
|
|
build_pdf:
|
|
required: false
|
|
type: boolean
|
|
default: true
|
|
build_epub:
|
|
required: false
|
|
type: boolean
|
|
default: true
|
|
build_target:
|
|
required: false
|
|
type: string
|
|
default: 'all'
|
|
target:
|
|
required: false
|
|
type: string
|
|
default: 'dev'
|
|
container_registry:
|
|
required: false
|
|
type: string
|
|
default: 'ghcr.io'
|
|
container_tag:
|
|
required: false
|
|
type: string
|
|
default: 'latest'
|
|
outputs:
|
|
build_success:
|
|
description: "Whether all builds completed successfully"
|
|
value: ${{ jobs.collect-outputs.outputs.build_success }}
|
|
build_target:
|
|
description: "Build target used (vol1/vol2/all)"
|
|
value: ${{ jobs.collect-outputs.outputs.build_target }}
|
|
failed_instances:
|
|
description: "Failed matrix build instances"
|
|
value: ${{ jobs.collect-outputs.outputs.failed_instances }}
|
|
cancelled_instances:
|
|
description: "Cancelled matrix build instances"
|
|
value: ${{ jobs.collect-outputs.outputs.cancelled_instances }}
|
|
# Volume I artifacts
|
|
linux_html_vol1_artifact:
|
|
description: "Linux HTML artifact name (Volume I)"
|
|
value: ${{ jobs.collect-outputs.outputs.linux_html_vol1_artifact }}
|
|
linux_pdf_vol1_artifact:
|
|
description: "Linux PDF artifact name (Volume I)"
|
|
value: ${{ jobs.collect-outputs.outputs.linux_pdf_vol1_artifact }}
|
|
linux_epub_vol1_artifact:
|
|
description: "Linux EPUB artifact name (Volume I)"
|
|
value: ${{ jobs.collect-outputs.outputs.linux_epub_vol1_artifact }}
|
|
# Volume II artifacts
|
|
linux_html_vol2_artifact:
|
|
description: "Linux HTML artifact name (Volume II)"
|
|
value: ${{ jobs.collect-outputs.outputs.linux_html_vol2_artifact }}
|
|
linux_pdf_vol2_artifact:
|
|
description: "Linux PDF artifact name (Volume II)"
|
|
value: ${{ jobs.collect-outputs.outputs.linux_pdf_vol2_artifact }}
|
|
linux_epub_vol2_artifact:
|
|
description: "Linux EPUB artifact name (Volume II)"
|
|
value: ${{ jobs.collect-outputs.outputs.linux_epub_vol2_artifact }}
|
|
# Windows Volume I artifacts
|
|
windows_html_vol1_artifact:
|
|
description: "Windows HTML artifact name (Volume I)"
|
|
value: ${{ jobs.collect-outputs.outputs.windows_html_vol1_artifact }}
|
|
windows_pdf_vol1_artifact:
|
|
description: "Windows PDF artifact name (Volume I)"
|
|
value: ${{ jobs.collect-outputs.outputs.windows_pdf_vol1_artifact }}
|
|
windows_epub_vol1_artifact:
|
|
description: "Windows EPUB artifact name (Volume I)"
|
|
value: ${{ jobs.collect-outputs.outputs.windows_epub_vol1_artifact }}
|
|
# Windows Volume II artifacts
|
|
windows_html_vol2_artifact:
|
|
description: "Windows HTML artifact name (Volume II)"
|
|
value: ${{ jobs.collect-outputs.outputs.windows_html_vol2_artifact }}
|
|
windows_pdf_vol2_artifact:
|
|
description: "Windows PDF artifact name (Volume II)"
|
|
value: ${{ jobs.collect-outputs.outputs.windows_pdf_vol2_artifact }}
|
|
windows_epub_vol2_artifact:
|
|
description: "Windows EPUB artifact name (Volume II)"
|
|
value: ${{ jobs.collect-outputs.outputs.windows_epub_vol2_artifact }}
|
|
|
|
permissions:
|
|
contents: read
|
|
packages: read
|
|
actions: read
|
|
|
|
# =============================================================================
|
|
# PATH CONFIGURATION - Uses GitHub Repository Variables (Settings > Variables)
|
|
# =============================================================================
|
|
# MLSysBook content lives under book/ to accommodate TinyTorch at root
|
|
# Use ${{ vars.BOOK_ROOT }}, ${{ vars.BOOK_QUARTO }}, etc. in workflow steps
|
|
# Variables: BOOK_ROOT, BOOK_DOCKER, BOOK_TOOLS, BOOK_QUARTO, BOOK_DEPS
|
|
|
|
jobs:
|
|
build:
|
|
name: '${{ matrix.platform_emoji }} Build ${{ matrix.platform_name }} · Vol ${{ matrix.volume == ''vol1'' && ''I'' || ''II'' }} (${{ matrix.format_emoji }} ${{ matrix.format_name }})'
|
|
runs-on: ${{ matrix.runner }}
|
|
timeout-minutes: 60
|
|
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
include:
|
|
# =================================================================
|
|
# Volume I builds
|
|
# =================================================================
|
|
- platform: linux
|
|
platform_name: Linux
|
|
platform_emoji: '🐧'
|
|
runner: ubuntu-latest
|
|
format_name: HTML
|
|
format_emoji: '📄'
|
|
volume: vol1
|
|
config: _quarto-html-vol1.yml
|
|
render_target: html
|
|
enabled: ${{ inputs.build_linux && inputs.build_html && (inputs.build_target == 'vol1' || inputs.build_target == 'all') }}
|
|
artifact_name: ${{ inputs.target }}-html-vol1-linux
|
|
output_dir: _build/html-vol1
|
|
- platform: linux
|
|
platform_name: Linux
|
|
platform_emoji: '🐧'
|
|
runner: ubuntu-latest
|
|
format_name: PDF
|
|
format_emoji: '📑'
|
|
volume: vol1
|
|
config: _quarto-pdf-vol1.yml
|
|
render_target: titlepage-pdf
|
|
enabled: ${{ inputs.build_linux && inputs.build_pdf && (inputs.build_target == 'vol1' || inputs.build_target == 'all') }}
|
|
artifact_name: ${{ inputs.target }}-pdf-vol1-linux
|
|
output_dir: _build/pdf-vol1
|
|
- platform: linux
|
|
platform_name: Linux
|
|
platform_emoji: '🐧'
|
|
runner: ubuntu-latest
|
|
format_name: EPUB
|
|
format_emoji: '📚'
|
|
volume: vol1
|
|
config: _quarto-epub-vol1.yml
|
|
render_target: epub
|
|
enabled: ${{ inputs.build_linux && inputs.build_epub && (inputs.build_target == 'vol1' || inputs.build_target == 'all') }}
|
|
artifact_name: ${{ inputs.target }}-epub-vol1-linux
|
|
output_dir: _build/epub-vol1
|
|
# =================================================================
|
|
# Volume II builds
|
|
# =================================================================
|
|
- platform: linux
|
|
platform_name: Linux
|
|
platform_emoji: '🐧'
|
|
runner: ubuntu-latest
|
|
format_name: HTML
|
|
format_emoji: '📄'
|
|
volume: vol2
|
|
config: _quarto-html-vol2.yml
|
|
render_target: html
|
|
enabled: ${{ inputs.build_linux && inputs.build_html && (inputs.build_target == 'vol2' || inputs.build_target == 'all') }}
|
|
artifact_name: ${{ inputs.target }}-html-vol2-linux
|
|
output_dir: _build/html-vol2
|
|
- platform: linux
|
|
platform_name: Linux
|
|
platform_emoji: '🐧'
|
|
runner: ubuntu-latest
|
|
format_name: PDF
|
|
format_emoji: '📑'
|
|
volume: vol2
|
|
config: _quarto-pdf-vol2.yml
|
|
render_target: titlepage-pdf
|
|
enabled: ${{ inputs.build_linux && inputs.build_pdf && (inputs.build_target == 'vol2' || inputs.build_target == 'all') }}
|
|
artifact_name: ${{ inputs.target }}-pdf-vol2-linux
|
|
output_dir: _build/pdf-vol2
|
|
- platform: linux
|
|
platform_name: Linux
|
|
platform_emoji: '🐧'
|
|
runner: ubuntu-latest
|
|
format_name: EPUB
|
|
format_emoji: '📚'
|
|
volume: vol2
|
|
config: _quarto-epub-vol2.yml
|
|
render_target: epub
|
|
enabled: ${{ inputs.build_linux && inputs.build_epub && (inputs.build_target == 'vol2' || inputs.build_target == 'all') }}
|
|
artifact_name: ${{ inputs.target }}-epub-vol2-linux
|
|
output_dir: _build/epub-vol2
|
|
# =================================================================
|
|
# Volume I builds (Windows)
|
|
# =================================================================
|
|
- platform: windows
|
|
platform_name: Windows
|
|
platform_emoji: '🪟'
|
|
runner: windows-latest
|
|
format_name: HTML
|
|
format_emoji: '📄'
|
|
volume: vol1
|
|
config: _quarto-html-vol1.yml
|
|
render_target: html
|
|
enabled: ${{ inputs.build_windows && inputs.build_html && (inputs.build_target == 'vol1' || inputs.build_target == 'all') }}
|
|
artifact_name: ${{ inputs.target }}-html-vol1-windows
|
|
output_dir: _build/html-vol1
|
|
- platform: windows
|
|
platform_name: Windows
|
|
platform_emoji: '🪟'
|
|
runner: windows-latest
|
|
format_name: PDF
|
|
format_emoji: '📑'
|
|
volume: vol1
|
|
config: _quarto-pdf-vol1.yml
|
|
render_target: titlepage-pdf
|
|
enabled: ${{ inputs.build_windows && inputs.build_pdf && (inputs.build_target == 'vol1' || inputs.build_target == 'all') }}
|
|
artifact_name: ${{ inputs.target }}-pdf-vol1-windows
|
|
output_dir: _build/pdf-vol1
|
|
- platform: windows
|
|
platform_name: Windows
|
|
platform_emoji: '🪟'
|
|
runner: windows-latest
|
|
format_name: EPUB
|
|
format_emoji: '📚'
|
|
volume: vol1
|
|
config: _quarto-epub-vol1.yml
|
|
render_target: epub
|
|
enabled: ${{ inputs.build_windows && inputs.build_epub && (inputs.build_target == 'vol1' || inputs.build_target == 'all') }}
|
|
artifact_name: ${{ inputs.target }}-epub-vol1-windows
|
|
output_dir: _build/epub-vol1
|
|
# =================================================================
|
|
# Volume II builds (Windows)
|
|
# =================================================================
|
|
- platform: windows
|
|
platform_name: Windows
|
|
platform_emoji: '🪟'
|
|
runner: windows-latest
|
|
format_name: HTML
|
|
format_emoji: '📄'
|
|
volume: vol2
|
|
config: _quarto-html-vol2.yml
|
|
render_target: html
|
|
enabled: ${{ inputs.build_windows && inputs.build_html && (inputs.build_target == 'vol2' || inputs.build_target == 'all') }}
|
|
artifact_name: ${{ inputs.target }}-html-vol2-windows
|
|
output_dir: _build/html-vol2
|
|
- platform: windows
|
|
platform_name: Windows
|
|
platform_emoji: '🪟'
|
|
runner: windows-latest
|
|
format_name: PDF
|
|
format_emoji: '📑'
|
|
volume: vol2
|
|
config: _quarto-pdf-vol2.yml
|
|
render_target: titlepage-pdf
|
|
enabled: ${{ inputs.build_windows && inputs.build_pdf && (inputs.build_target == 'vol2' || inputs.build_target == 'all') }}
|
|
artifact_name: ${{ inputs.target }}-pdf-vol2-windows
|
|
output_dir: _build/pdf-vol2
|
|
- platform: windows
|
|
platform_name: Windows
|
|
platform_emoji: '🪟'
|
|
runner: windows-latest
|
|
format_name: EPUB
|
|
format_emoji: '📚'
|
|
volume: vol2
|
|
config: _quarto-epub-vol2.yml
|
|
render_target: epub
|
|
enabled: ${{ inputs.build_windows && inputs.build_epub && (inputs.build_target == 'vol2' || inputs.build_target == 'all') }}
|
|
artifact_name: ${{ inputs.target }}-epub-vol2-windows
|
|
output_dir: _build/epub-vol2
|
|
|
|
outputs:
|
|
platform: ${{ matrix.platform }}
|
|
format: ${{ matrix.format_name }}
|
|
artifact_name: ${{ matrix.artifact_name }}
|
|
output_dir: ${{ matrix.output_dir }}
|
|
|
|
# Only Linux runs in containers
|
|
container: ${{ matrix.platform == 'linux' && format('{0}/{1}/quarto-{2}:{3}', inputs.container_registry || 'ghcr.io', github.repository, matrix.platform, inputs.container_tag || 'latest') || null }}
|
|
|
|
env:
|
|
CONTAINER_IMAGE: ${{ format('{0}/{1}/quarto-{2}:{3}', inputs.container_registry || 'ghcr.io', github.repository, matrix.platform, inputs.container_tag || 'latest') }}
|
|
# Using vars.BOOK_DOCKER (repository variable) - works in all contexts
|
|
DOCKERFILE_PATH: ./${{ vars.BOOK_DOCKER }}/${{ matrix.platform }}/Dockerfile
|
|
|
|
steps:
|
|
- name: 🛑 Skip build
|
|
if: "!matrix.enabled"
|
|
run: echo "Build skipped because matrix.enabled is false"
|
|
|
|
- name: 📥 Checkout repository
|
|
if: matrix.enabled
|
|
uses: actions/checkout@v6
|
|
with:
|
|
ref: ${{ inputs.target }}
|
|
fetch-depth: 0
|
|
|
|
- name: 🔍 Debug build configuration
|
|
if: matrix.enabled
|
|
run: |
|
|
echo "🎯 Target branch: ${{ inputs.target }}"
|
|
echo "🐧 Build Linux: ${{ inputs.build_linux }}"
|
|
echo "🪟 Build Windows: ${{ inputs.build_windows }}"
|
|
echo "📄 Build HTML: ${{ inputs.build_html }}"
|
|
echo "📑 Build PDF: ${{ inputs.build_pdf }}"
|
|
echo "📚 Build EPUB: ${{ inputs.build_epub }}"
|
|
echo "🐳 Container registry: ${{ inputs.container_registry }}"
|
|
echo "🏷️ Container tag: ${{ inputs.container_tag }}"
|
|
echo "✅ Current matrix job enabled: ${{ matrix.enabled }}"
|
|
|
|
- name: 🔑 Log in to GitHub Container Registry
|
|
if: matrix.platform == 'windows' && matrix.enabled
|
|
uses: docker/login-action@v3
|
|
with:
|
|
registry: ${{ inputs.container_registry }}
|
|
username: ${{ github.actor }}
|
|
password: ${{ secrets.GITHUB_TOKEN }}
|
|
|
|
- name: 🐳 Ensure Docker daemon (Windows)
|
|
if: matrix.platform == 'windows' && matrix.enabled
|
|
shell: pwsh
|
|
run: |
|
|
$ErrorActionPreference = 'Stop'
|
|
$dockerService = Get-Service -Name docker -ErrorAction Stop
|
|
|
|
if ($dockerService.Status -ne 'Running') {
|
|
Write-Host "Starting Docker service..."
|
|
Start-Service -Name docker
|
|
} else {
|
|
Write-Host "Docker service already running."
|
|
}
|
|
|
|
$maxAttempts = 12
|
|
for ($i = 1; $i -le $maxAttempts; $i++) {
|
|
try {
|
|
docker version | Out-Null
|
|
Write-Host "Docker daemon is ready."
|
|
break
|
|
} catch {
|
|
if ($i -eq $maxAttempts) {
|
|
throw "Docker daemon failed to start after waiting."
|
|
}
|
|
Start-Sleep -Seconds 5
|
|
}
|
|
}
|
|
|
|
- name: 🐳 Pull Docker Image
|
|
if: matrix.platform == 'windows' && matrix.enabled
|
|
run: docker pull ${{ env.CONTAINER_IMAGE }}
|
|
|
|
- name: 🧪 Preflight toolchain (Linux)
|
|
if: matrix.platform == 'linux' && matrix.enabled
|
|
working-directory: ${{ vars.BOOK_QUARTO }}
|
|
env:
|
|
PYTHONPATH: ${{ github.workspace }}
|
|
run: |
|
|
set -eu
|
|
echo "🧪 Running Linux toolchain preflight checks..."
|
|
run_check() {
|
|
CHECK_NAME="$1"
|
|
CHECK_CMD="$2"
|
|
echo "▶ preflight: $CHECK_NAME"
|
|
if sh -c "$CHECK_CMD"; then
|
|
echo "✅ preflight: $CHECK_NAME"
|
|
else
|
|
echo "❌ preflight failed: $CHECK_NAME"
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
run_check "quarto on PATH" "command -v quarto >/dev/null"
|
|
run_check "quarto version" "quarto --version | sed -n '1p'"
|
|
run_check "pandoc available" "command -v pandoc >/dev/null || quarto pandoc --version >/dev/null 2>&1"
|
|
run_check "pandoc version" "if command -v pandoc >/dev/null; then pandoc --version | sed -n '1p'; else quarto pandoc --version | sed -n '1p'; fi"
|
|
run_check "python3 on PATH" "command -v python3 >/dev/null"
|
|
run_check "python3 version" "python3 --version"
|
|
run_check "mlsysim import" "python3 -c \"import mlsysim,sys; print('mlsysim:', mlsysim.__file__); print('python:', sys.executable)\""
|
|
run_check "Rscript on PATH" "command -v Rscript >/dev/null"
|
|
run_check "Rscript version" "Rscript --version | sed -n '1p'"
|
|
run_check "inkscape on PATH" "command -v inkscape >/dev/null"
|
|
run_check "inkscape version" "inkscape --version | sed -n '1p'"
|
|
|
|
if [ "${{ matrix.format_name }}" = "PDF" ]; then
|
|
run_check "lualatex on PATH" "command -v lualatex >/dev/null"
|
|
run_check "lualatex version" "lualatex --version | sed -n '1p'"
|
|
run_check "ghostscript (gs) on PATH" "command -v gs >/dev/null"
|
|
run_check "ghostscript version" "gs --version"
|
|
fi
|
|
|
|
if [ "${{ matrix.format_name }}" = "EPUB" ]; then
|
|
run_check "Pillow import" "python3 -c \"import PIL; print('Pillow:', PIL.__version__)\""
|
|
fi
|
|
|
|
echo "✅ Linux toolchain preflight checks passed"
|
|
|
|
- name: 🧪 Preflight toolchain (Windows)
|
|
if: matrix.platform == 'windows' && matrix.enabled
|
|
shell: pwsh
|
|
run: |
|
|
Write-Host "🧪 Running Windows toolchain preflight checks..."
|
|
docker run --rm -e PYTHONPATH=C:\workspace -v "$($PWD.Path):C:\workspace" -w "C:\workspace\book\quarto" ${{ env.CONTAINER_IMAGE }} pwsh -NoLogo -Command "
|
|
`$ErrorActionPreference = 'Stop'
|
|
`$PSNativeCommandUseErrorActionPreference = `$true
|
|
|
|
function Invoke-Check {
|
|
param(
|
|
[string]`$Name,
|
|
[scriptblock]`$Action
|
|
)
|
|
Write-Host \"▶ preflight: `$Name\"
|
|
try {
|
|
& `$Action
|
|
if (-not `$?) { throw 'Command returned non-zero status' }
|
|
Write-Host \"✅ preflight: `$Name\"
|
|
} catch {
|
|
`$errorMessage = `$_.Exception.Message
|
|
Write-Error (\"❌ preflight failed: `$Name -- {0}\" -f `$errorMessage)
|
|
throw
|
|
}
|
|
}
|
|
|
|
Invoke-Check 'quarto on PATH' { `$resolved = Get-Command quarto -ErrorAction Stop; Write-Host (\" quarto -> {0}\" -f `$resolved.Source) }
|
|
Invoke-Check 'quarto version' { quarto --version | Select-Object -First 1 }
|
|
Invoke-Check 'pandoc available' {
|
|
`$pandocCmd = Get-Command pandoc -ErrorAction SilentlyContinue
|
|
if (`$pandocCmd) {
|
|
Write-Host (\" pandoc -> {0}\" -f `$pandocCmd.Source)
|
|
} else {
|
|
quarto pandoc --version | Select-Object -First 1 | Out-Null
|
|
Write-Host \" pandoc -> bundled via quarto\"
|
|
}
|
|
}
|
|
Invoke-Check 'pandoc version' {
|
|
`$pandocCmd = Get-Command pandoc -ErrorAction SilentlyContinue
|
|
if (`$pandocCmd) {
|
|
pandoc --version | Select-Object -First 1
|
|
} else {
|
|
quarto pandoc --version | Select-Object -First 1
|
|
}
|
|
}
|
|
Invoke-Check 'python on PATH' { `$resolved = Get-Command python -ErrorAction Stop; Write-Host (\" python -> {0}\" -f `$resolved.Source) }
|
|
Invoke-Check 'python version' { python --version }
|
|
Invoke-Check 'python3 on PATH' { `$resolved = Get-Command python3 -ErrorAction Stop; Write-Host (\" python3 -> {0}\" -f `$resolved.Source) }
|
|
Invoke-Check 'python3 version' { python3 --version }
|
|
Invoke-Check 'mlsysim import' { python -c 'import mlsysim,sys; print(\"mlsysim:\", mlsysim.__file__); print(\"python:\", sys.executable)' }
|
|
Invoke-Check 'Rscript on PATH' { `$resolved = Get-Command Rscript -ErrorAction Stop; Write-Host (\" Rscript -> {0}\" -f `$resolved.Source) }
|
|
Invoke-Check 'Rscript version' { Rscript --version | Select-Object -First 1 }
|
|
Invoke-Check 'inkscape on PATH' { `$resolved = Get-Command inkscape -ErrorAction Stop; Write-Host (\" inkscape -> {0}\" -f `$resolved.Source) }
|
|
Invoke-Check 'inkscape version' { inkscape --version | Select-Object -First 1 }
|
|
|
|
if ('${{ matrix.format_name }}' -eq 'PDF') {
|
|
Invoke-Check 'lualatex on PATH' { `$resolved = Get-Command lualatex -ErrorAction Stop; Write-Host (\" lualatex -> {0}\" -f `$resolved.Source) }
|
|
Invoke-Check 'lualatex version' { lualatex --version | Select-Object -First 1 }
|
|
Invoke-Check 'ghostscript on PATH' {
|
|
`$gsCmd = Get-Command gs -ErrorAction SilentlyContinue
|
|
if (-not `$gsCmd) { `$gsCmd = Get-Command gswin64c -ErrorAction SilentlyContinue }
|
|
if (-not `$gsCmd) { throw 'Ghostscript not found (expected gs or gswin64c)' }
|
|
Write-Host (\" ghostscript -> {0}\" -f `$gsCmd.Source)
|
|
}
|
|
Invoke-Check 'ghostscript version' {
|
|
`$gsCmd = Get-Command gs -ErrorAction SilentlyContinue
|
|
if (-not `$gsCmd) { `$gsCmd = Get-Command gswin64c -ErrorAction SilentlyContinue }
|
|
& `$gsCmd.Source --version
|
|
}
|
|
}
|
|
|
|
if ('${{ matrix.format_name }}' -eq 'EPUB') {
|
|
Invoke-Check 'Pillow import' { python -c 'import PIL; print(\"Pillow:\", PIL.__version__)' }
|
|
}
|
|
"
|
|
Write-Host "✅ Windows toolchain preflight checks passed"
|
|
|
|
- name: 🔨 Build ${{ matrix.format_name }} (Linux)
|
|
if: matrix.platform == 'linux' && matrix.enabled
|
|
working-directory: ${{ vars.BOOK_QUARTO }}
|
|
env:
|
|
PYTHONPATH: ${{ github.workspace }}
|
|
run: |
|
|
echo "🔨 Building ${{ matrix.format_name }} on Linux container..."
|
|
rm -f _quarto.yml
|
|
cp config/${{ matrix.config }} _quarto.yml
|
|
quarto render --to ${{ matrix.render_target }} --output-dir "${{ matrix.output_dir }}"
|
|
echo "✅ ${{ matrix.format_name }} build completed"
|
|
|
|
- name: 🔨 Build ${{ matrix.format_name }} (Windows)
|
|
if: matrix.platform == 'windows' && matrix.enabled
|
|
shell: pwsh
|
|
run: |
|
|
Write-Host "🔨 Building ${{ matrix.format_name }} on Windows container..."
|
|
docker run --rm -e PYTHONPATH=C:\workspace -v "$($PWD.Path):C:\workspace" -w "C:\workspace\book\quarto" ${{ env.CONTAINER_IMAGE }} pwsh -NoLogo -Command "
|
|
if (Test-Path '_quarto.yml') { Remove-Item '_quarto.yml' -Force }
|
|
Copy-Item 'config\${{ matrix.config }}' '_quarto.yml' -Force
|
|
quarto render --to ${{ matrix.render_target }} --output-dir '${{ matrix.output_dir }}'
|
|
"
|
|
Write-Host "✅ ${{ matrix.format_name }} build completed"
|
|
|
|
- name: 📉 Compress PDF (Linux)
|
|
if: matrix.platform == 'linux' && matrix.format_name == 'PDF' && matrix.enabled
|
|
working-directory: ${{ vars.BOOK_QUARTO }}/${{ matrix.output_dir }}
|
|
run: |
|
|
if [ -f "Machine-Learning-Systems.pdf" ]; then
|
|
echo "📉 Compressing PDF with professional compression tool..."
|
|
echo "🔍 DEBUG: PWD=$(pwd)"
|
|
echo "🔍 DEBUG: Checking for compress script:"
|
|
ls -la ../../publish/compress_pdf.py || echo "❌ Script not found at ../../publish/"
|
|
|
|
# Use relative path from current working directory (book/quarto/_build/pdf)
|
|
SCRIPT_PATH="../../publish/compress_pdf.py"
|
|
if [ -f "$SCRIPT_PATH" ]; then
|
|
echo "✅ Using script at: $SCRIPT_PATH"
|
|
python3 "$SCRIPT_PATH" \
|
|
--input "Machine-Learning-Systems.pdf" \
|
|
--output "compressed.pdf" \
|
|
--quality minimal \
|
|
--verbose
|
|
else
|
|
# Fallback to absolute path via github.workspace
|
|
SCRIPT_PATH="${{ github.workspace }}/${{ vars.BOOK_QUARTO }}/publish/compress_pdf.py"
|
|
echo "🔄 Trying fallback path: $SCRIPT_PATH"
|
|
python3 "$SCRIPT_PATH" \
|
|
--input "Machine-Learning-Systems.pdf" \
|
|
--output "compressed.pdf" \
|
|
--quality minimal \
|
|
--verbose
|
|
fi
|
|
mv compressed.pdf Machine-Learning-Systems.pdf
|
|
echo "✅ PDF compression completed"
|
|
else
|
|
echo "⚠️ PDF file not found for compression"
|
|
fi
|
|
|
|
- name: 📉 Compress PDF (Windows)
|
|
if: matrix.platform == 'windows' && matrix.format_name == 'PDF' && matrix.enabled
|
|
shell: pwsh
|
|
run: |
|
|
Write-Host "🔨 Compressing PDF on Windows container..."
|
|
docker run --rm -e PYTHONPATH=C:\workspace -v "$($PWD.Path):C:\workspace" -w "C:\workspace\book\quarto\${{ matrix.output_dir }}" ${{ env.CONTAINER_IMAGE }} pwsh -NoLogo -Command "
|
|
if (Test-Path 'Machine-Learning-Systems.pdf') {
|
|
Write-Host '📉 Compressing PDF with professional compression tool...'
|
|
Write-Host '🔍 DEBUG: Current directory:'
|
|
Get-Location
|
|
Write-Host '🔍 DEBUG: Windows environment info:'
|
|
Write-Host \"OS: $([System.Environment]::OSVersion.VersionString)\"
|
|
Write-Host \"Architecture: $([System.Environment]::Is64BitOperatingSystem)\"
|
|
Write-Host '🔍 DEBUG: Verifying Python and Pillow installation:'
|
|
`$py_version = (python --version 2>&1).Trim()
|
|
Write-Host \" 🐍 Python Version: `$py_version\"
|
|
`$py_path = (python -c 'import sys; print(sys.executable)').Trim()
|
|
Write-Host \" 🐍 Python Path: `$py_path\"
|
|
`$pillow_version = (python -c 'import PIL; print(PIL.__version__)').Trim()
|
|
Write-Host \" ✅ Pillow Version: `$pillow_version\"
|
|
Write-Host '🔍 DEBUG: Checking for script at relative path:'
|
|
if (Test-Path '..\\..\\publish\\compress_pdf.py') {
|
|
Write-Host '✅ Found script at ..\\..\\publish\\compress_pdf.py'
|
|
python ..\..\publish\compress_pdf.py --input 'Machine-Learning-Systems.pdf' --output 'compressed.pdf' --quality minimal --verbose
|
|
} else {
|
|
Write-Host '🔄 Trying fallback path: C:\workspace\book\quarto\publish\compress_pdf.py'
|
|
python C:\workspace\book\quarto\publish\compress_pdf.py --input 'Machine-Learning-Systems.pdf' --output 'compressed.pdf' --quality minimal --verbose
|
|
}
|
|
if (Test-Path 'compressed.pdf') {
|
|
Move-Item -Force 'compressed.pdf' 'Machine-Learning-Systems.pdf'
|
|
Write-Host '✅ PDF compression completed'
|
|
}
|
|
} else {
|
|
Write-Warning '⚠️ Machine-Learning-Systems.pdf not found for compression.'
|
|
}
|
|
"
|
|
Write-Host "✅ PDF compression completed."
|
|
|
|
- name: 📚 Compress EPUB (Linux)
|
|
if: matrix.platform == 'linux' && matrix.format_name == 'EPUB' && matrix.enabled
|
|
working-directory: ${{ vars.BOOK_QUARTO }}/${{ matrix.output_dir }}
|
|
run: |
|
|
if [ -f "Machine-Learning-Systems.epub" ]; then
|
|
echo "📚 Compressing EPUB with optimized compression tool..."
|
|
echo "🔍 DEBUG: PWD=$(pwd)"
|
|
echo "🔍 DEBUG: Repository structure from current directory:"
|
|
ls -la ../../ | head -10
|
|
echo "🔍 DEBUG: Checking for compress script:"
|
|
ls -la ../../publish/compress_epub.py || echo "❌ Script not found at ../../publish/"
|
|
echo "🔍 DEBUG: Checking fallback path:"
|
|
ls -la "${{ github.workspace }}/${{ vars.BOOK_QUARTO }}/publish/compress_epub.py" || echo "❌ Script not found at github.workspace path"
|
|
|
|
# Use relative path from current working directory (book/quarto/_build/epub)
|
|
SCRIPT_PATH="../../publish/compress_epub.py"
|
|
if [ -f "$SCRIPT_PATH" ]; then
|
|
echo "✅ Using script at: $SCRIPT_PATH"
|
|
python3 "$SCRIPT_PATH" \
|
|
--input "Machine-Learning-Systems.epub" \
|
|
--output "compressed.epub" \
|
|
--verbose
|
|
else
|
|
# Fallback to absolute path via github.workspace
|
|
SCRIPT_PATH="${{ github.workspace }}/${{ vars.BOOK_QUARTO }}/publish/compress_epub.py"
|
|
echo "🔄 Trying fallback path: $SCRIPT_PATH"
|
|
python3 "$SCRIPT_PATH" \
|
|
--input "Machine-Learning-Systems.epub" \
|
|
--output "compressed.epub" \
|
|
--verbose
|
|
fi
|
|
mv compressed.epub Machine-Learning-Systems.epub
|
|
echo "✅ EPUB compression completed (using optimized defaults: quality=50, max-size=1000px)"
|
|
else
|
|
echo "⚠️ EPUB file not found for compression"
|
|
fi
|
|
|
|
- name: 📚 Compress EPUB (Windows)
|
|
if: matrix.platform == 'windows' && matrix.format_name == 'EPUB' && matrix.enabled
|
|
shell: pwsh
|
|
run: |
|
|
Write-Host "🔨 Compressing EPUB on Windows container..."
|
|
docker run --rm -e PYTHONPATH=C:\workspace -v "$($PWD.Path):C:\workspace" -w "C:\workspace\book\quarto\${{ matrix.output_dir }}" ${{ env.CONTAINER_IMAGE }} pwsh -NoLogo -Command "
|
|
if (Test-Path 'Machine-Learning-Systems.epub') {
|
|
Write-Host '📚 Compressing EPUB with optimized compression tool...'
|
|
Write-Host '🔍 DEBUG: Current directory:'
|
|
Get-Location
|
|
Write-Host '🔍 DEBUG: Windows environment info:'
|
|
Write-Host \"OS: $([System.Environment]::OSVersion.VersionString)\"
|
|
Write-Host \"Architecture: $([System.Environment]::Is64BitOperatingSystem)\"
|
|
Write-Host '🔍 DEBUG: Verifying Python and Pillow installation:'
|
|
`$py_version = (python --version 2>&1).Trim()
|
|
Write-Host \" 🐍 Python Version: `$py_version\"
|
|
`$py_path = (python -c 'import sys; print(sys.executable)').Trim()
|
|
Write-Host \" 🐍 Python Path: `$py_path\"
|
|
`$pillow_version = (python -c 'import PIL; print(PIL.__version__)').Trim()
|
|
Write-Host \" ✅ Pillow Version: `$pillow_version\"
|
|
Write-Host '🔍 DEBUG: Checking for script at relative path:'
|
|
if (Test-Path '..\\..\\publish\\compress_epub.py') {
|
|
Write-Host '✅ Found script at ..\\..\\publish\\compress_epub.py'
|
|
python ..\..\publish\compress_epub.py --input 'Machine-Learning-Systems.epub' --output 'compressed.epub' --verbose
|
|
} else {
|
|
Write-Host '🔄 Trying fallback path: C:\workspace\book\quarto\publish\compress_epub.py'
|
|
python C:\workspace\book\quarto\publish\compress_epub.py --input 'Machine-Learning-Systems.epub' --output 'compressed.epub' --verbose
|
|
}
|
|
if (Test-Path 'compressed.epub') {
|
|
Move-Item -Force 'compressed.epub' 'Machine-Learning-Systems.epub'
|
|
Write-Host '✅ EPUB compression completed (using optimized defaults: quality=50, max-size=1000px)'
|
|
}
|
|
} else {
|
|
Write-Warning '⚠️ Machine-Learning-Systems.epub not found for compression.'
|
|
}
|
|
"
|
|
Write-Host "✅ EPUB compression completed."
|
|
|
|
- name: 📤 Upload artifact
|
|
uses: actions/upload-artifact@v6
|
|
if: matrix.enabled
|
|
with:
|
|
name: ${{ matrix.artifact_name }}
|
|
path: ${{ vars.BOOK_QUARTO }}/${{ matrix.output_dir }}
|
|
|
|
collect-outputs:
|
|
name: '📊 Collect Outputs'
|
|
needs: build
|
|
runs-on: ubuntu-latest
|
|
if: always()
|
|
outputs:
|
|
build_success: ${{ steps.collect.outputs.build_success }}
|
|
build_target: ${{ steps.collect.outputs.build_target }}
|
|
failed_instances: ${{ steps.matrix-status.outputs.failed_instances }}
|
|
cancelled_instances: ${{ steps.matrix-status.outputs.cancelled_instances }}
|
|
# Volume I artifacts
|
|
linux_html_vol1_artifact: ${{ steps.collect.outputs.linux_html_vol1_artifact }}
|
|
linux_pdf_vol1_artifact: ${{ steps.collect.outputs.linux_pdf_vol1_artifact }}
|
|
linux_epub_vol1_artifact: ${{ steps.collect.outputs.linux_epub_vol1_artifact }}
|
|
# Volume II artifacts
|
|
linux_html_vol2_artifact: ${{ steps.collect.outputs.linux_html_vol2_artifact }}
|
|
linux_pdf_vol2_artifact: ${{ steps.collect.outputs.linux_pdf_vol2_artifact }}
|
|
linux_epub_vol2_artifact: ${{ steps.collect.outputs.linux_epub_vol2_artifact }}
|
|
# Windows Volume I artifacts
|
|
windows_html_vol1_artifact: ${{ steps.collect.outputs.windows_html_vol1_artifact }}
|
|
windows_pdf_vol1_artifact: ${{ steps.collect.outputs.windows_pdf_vol1_artifact }}
|
|
windows_epub_vol1_artifact: ${{ steps.collect.outputs.windows_epub_vol1_artifact }}
|
|
# Windows Volume II artifacts
|
|
windows_html_vol2_artifact: ${{ steps.collect.outputs.windows_html_vol2_artifact }}
|
|
windows_pdf_vol2_artifact: ${{ steps.collect.outputs.windows_pdf_vol2_artifact }}
|
|
windows_epub_vol2_artifact: ${{ steps.collect.outputs.windows_epub_vol2_artifact }}
|
|
|
|
steps:
|
|
- name: 🔎 Collect matrix job status details
|
|
id: matrix-status
|
|
uses: actions/github-script@v7
|
|
with:
|
|
script: |
|
|
const runId = context.runId;
|
|
const { owner, repo } = context.repo;
|
|
|
|
const allJobs = [];
|
|
for (let page = 1; ; page++) {
|
|
const resp = await github.rest.actions.listJobsForWorkflowRun({
|
|
owner,
|
|
repo,
|
|
run_id: runId,
|
|
per_page: 100,
|
|
page,
|
|
});
|
|
allJobs.push(...resp.data.jobs);
|
|
if (resp.data.jobs.length < 100) break;
|
|
}
|
|
|
|
const matrixBuildJobs = allJobs.filter((job) => {
|
|
return job.name.includes(" Build ") && job.name.includes("Vol ");
|
|
});
|
|
|
|
const failedJobs = matrixBuildJobs.filter((job) =>
|
|
["failure", "timed_out", "action_required", "stale"].includes(job.conclusion || "")
|
|
);
|
|
const cancelledJobs = matrixBuildJobs.filter((job) =>
|
|
(job.conclusion || "") === "cancelled"
|
|
);
|
|
|
|
const failedNames = failedJobs.map((job) => job.name);
|
|
const cancelledNames = cancelledJobs.map((job) => job.name);
|
|
|
|
core.info(`Detected ${failedNames.length} failed matrix instance(s).`);
|
|
core.info(`Detected ${cancelledNames.length} cancelled matrix instance(s).`);
|
|
core.setOutput("failed_instances", failedNames.join(" | "));
|
|
core.setOutput("cancelled_instances", cancelledNames.join(" | "));
|
|
|
|
- name: 📊 Collect results
|
|
id: collect
|
|
run: |
|
|
# Determine overall build success
|
|
if [[ "${{ needs.build.result }}" == "success" || "${{ needs.build.result }}" == "skipped" ]]; then
|
|
echo "build_success=true" >> $GITHUB_OUTPUT
|
|
BUILD_SUCCESS_MSG="✅ Success"
|
|
else
|
|
echo "build_success=false" >> $GITHUB_OUTPUT
|
|
BUILD_SUCCESS_MSG="❌ Failure"
|
|
fi
|
|
|
|
FAILED_INSTANCES="${{ steps.matrix-status.outputs.failed_instances }}"
|
|
CANCELLED_INSTANCES="${{ steps.matrix-status.outputs.cancelled_instances }}"
|
|
if [[ -n "$FAILED_INSTANCES" ]]; then
|
|
echo "❌ Failed matrix instances: $FAILED_INSTANCES"
|
|
fi
|
|
if [[ -n "$CANCELLED_INSTANCES" ]]; then
|
|
echo "⚠️ Cancelled matrix instances (often fail-fast cascade): $CANCELLED_INSTANCES"
|
|
fi
|
|
|
|
{
|
|
echo "## Container Matrix Result"
|
|
echo "- Overall: $BUILD_SUCCESS_MSG"
|
|
if [[ -n "$FAILED_INSTANCES" ]]; then
|
|
echo "- Failed instances: $FAILED_INSTANCES"
|
|
fi
|
|
if [[ -n "$CANCELLED_INSTANCES" ]]; then
|
|
echo "- Cancelled instances: $CANCELLED_INSTANCES"
|
|
fi
|
|
} >> "$GITHUB_STEP_SUMMARY"
|
|
|
|
# 🔌 API-style artifact name generation (reliable and predictable)
|
|
TARGET="${{ inputs.target }}"
|
|
BUILD_TARGET="${{ inputs.build_target }}"
|
|
echo "🔌 Generating artifact names for target: $TARGET, build_target: $BUILD_TARGET"
|
|
echo "build_target=$BUILD_TARGET" >> $GITHUB_OUTPUT
|
|
|
|
# =================================================================
|
|
# Volume I artifacts
|
|
# =================================================================
|
|
if [[ "$BUILD_TARGET" == "vol1" || "$BUILD_TARGET" == "all" ]]; then
|
|
if [[ "${{ inputs.build_linux && inputs.build_html }}" == "true" ]]; then
|
|
ARTIFACT_NAME="${TARGET}-html-vol1-linux"
|
|
echo "linux_html_vol1_artifact=$ARTIFACT_NAME" >> $GITHUB_OUTPUT
|
|
echo "📄 Linux HTML artifact (Vol I): $ARTIFACT_NAME"
|
|
fi
|
|
|
|
if [[ "${{ inputs.build_linux && inputs.build_pdf }}" == "true" ]]; then
|
|
ARTIFACT_NAME="${TARGET}-pdf-vol1-linux"
|
|
echo "linux_pdf_vol1_artifact=$ARTIFACT_NAME" >> $GITHUB_OUTPUT
|
|
echo "📑 Linux PDF artifact (Vol I): $ARTIFACT_NAME"
|
|
fi
|
|
|
|
if [[ "${{ inputs.build_linux && inputs.build_epub }}" == "true" ]]; then
|
|
ARTIFACT_NAME="${TARGET}-epub-vol1-linux"
|
|
echo "linux_epub_vol1_artifact=$ARTIFACT_NAME" >> $GITHUB_OUTPUT
|
|
echo "📚 Linux EPUB artifact (Vol I): $ARTIFACT_NAME"
|
|
fi
|
|
fi
|
|
|
|
# =================================================================
|
|
# Volume II artifacts
|
|
# =================================================================
|
|
if [[ "$BUILD_TARGET" == "vol2" || "$BUILD_TARGET" == "all" ]]; then
|
|
if [[ "${{ inputs.build_linux && inputs.build_html }}" == "true" ]]; then
|
|
ARTIFACT_NAME="${TARGET}-html-vol2-linux"
|
|
echo "linux_html_vol2_artifact=$ARTIFACT_NAME" >> $GITHUB_OUTPUT
|
|
echo "📄 Linux HTML artifact (Vol II): $ARTIFACT_NAME"
|
|
fi
|
|
|
|
if [[ "${{ inputs.build_linux && inputs.build_pdf }}" == "true" ]]; then
|
|
ARTIFACT_NAME="${TARGET}-pdf-vol2-linux"
|
|
echo "linux_pdf_vol2_artifact=$ARTIFACT_NAME" >> $GITHUB_OUTPUT
|
|
echo "📑 Linux PDF artifact (Vol II): $ARTIFACT_NAME"
|
|
fi
|
|
|
|
if [[ "${{ inputs.build_linux && inputs.build_epub }}" == "true" ]]; then
|
|
ARTIFACT_NAME="${TARGET}-epub-vol2-linux"
|
|
echo "linux_epub_vol2_artifact=$ARTIFACT_NAME" >> $GITHUB_OUTPUT
|
|
echo "📚 Linux EPUB artifact (Vol II): $ARTIFACT_NAME"
|
|
fi
|
|
fi
|
|
|
|
# =================================================================
|
|
# Windows Volume I artifacts
|
|
# =================================================================
|
|
if [[ "$BUILD_TARGET" == "vol1" || "$BUILD_TARGET" == "all" ]]; then
|
|
if [[ "${{ inputs.build_windows && inputs.build_html }}" == "true" ]]; then
|
|
ARTIFACT_NAME="${TARGET}-html-vol1-windows"
|
|
echo "windows_html_vol1_artifact=$ARTIFACT_NAME" >> $GITHUB_OUTPUT
|
|
echo "📄 Windows HTML artifact (Vol I): $ARTIFACT_NAME"
|
|
fi
|
|
|
|
if [[ "${{ inputs.build_windows && inputs.build_pdf }}" == "true" ]]; then
|
|
ARTIFACT_NAME="${TARGET}-pdf-vol1-windows"
|
|
echo "windows_pdf_vol1_artifact=$ARTIFACT_NAME" >> $GITHUB_OUTPUT
|
|
echo "📑 Windows PDF artifact (Vol I): $ARTIFACT_NAME"
|
|
fi
|
|
|
|
if [[ "${{ inputs.build_windows && inputs.build_epub }}" == "true" ]]; then
|
|
ARTIFACT_NAME="${TARGET}-epub-vol1-windows"
|
|
echo "windows_epub_vol1_artifact=$ARTIFACT_NAME" >> $GITHUB_OUTPUT
|
|
echo "📚 Windows EPUB artifact (Vol I): $ARTIFACT_NAME"
|
|
fi
|
|
fi
|
|
|
|
# =================================================================
|
|
# Windows Volume II artifacts
|
|
# =================================================================
|
|
if [[ "$BUILD_TARGET" == "vol2" || "$BUILD_TARGET" == "all" ]]; then
|
|
if [[ "${{ inputs.build_windows && inputs.build_html }}" == "true" ]]; then
|
|
ARTIFACT_NAME="${TARGET}-html-vol2-windows"
|
|
echo "windows_html_vol2_artifact=$ARTIFACT_NAME" >> $GITHUB_OUTPUT
|
|
echo "📄 Windows HTML artifact (Vol II): $ARTIFACT_NAME"
|
|
fi
|
|
|
|
if [[ "${{ inputs.build_windows && inputs.build_pdf }}" == "true" ]]; then
|
|
ARTIFACT_NAME="${TARGET}-pdf-vol2-windows"
|
|
echo "windows_pdf_vol2_artifact=$ARTIFACT_NAME" >> $GITHUB_OUTPUT
|
|
echo "📑 Windows PDF artifact (Vol II): $ARTIFACT_NAME"
|
|
fi
|
|
|
|
if [[ "${{ inputs.build_windows && inputs.build_epub }}" == "true" ]]; then
|
|
ARTIFACT_NAME="${TARGET}-epub-vol2-windows"
|
|
echo "windows_epub_vol2_artifact=$ARTIFACT_NAME" >> $GITHUB_OUTPUT
|
|
echo "📚 Windows EPUB artifact (Vol II): $ARTIFACT_NAME"
|
|
fi
|
|
fi
|
|
|
|
echo "✅ API contract established - Status: $BUILD_SUCCESS_MSG"
|