🔧 Infra · 🐳 Container (Linux) #21
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: '🔧 Infra · 🐳 Container (Linux)' | |
| # ============================================================================= | |
| # 🐳 Linux Container — Build and push the Quarto build container to GHCR | |
| # ============================================================================= | |
| # | |
| # Builds the Linux Docker image with all pre-installed dependencies (Quarto, | |
| # Python, R, TinyTeX) to eliminate 30-45 minute setup time in CI runs. | |
| # | |
| # Flow: | |
| # 1. ENVIRONMENT — Validate permissions, free disk space, configure Buildx | |
| # 2. REGISTRY — Authenticate to GHCR and verify write access | |
| # 3. BUILD & PUSH — Build container image with layer caching, push to GHCR | |
| # 4. SUMMARY — Report build status, image digest, and cache hit | |
| # | |
| # Triggers: | |
| # - push (dev): Changes to book/tools/dependencies/ or book/docker/linux/ | |
| # - schedule: Weekly rebuild (Sunday midnight UTC) | |
| # - workflow_dispatch: Manual with force_rebuild, no_cache options | |
| # - workflow_call: Reusable by other workflows | |
| # | |
| # Deploys to: ghcr.io/harvard-edge/mlsysbook/quarto-linux:latest | |
| # Secrets: GITHUB_TOKEN (automatic) | |
| # Vars: BOOK_DOCKER, BOOK_ROOT, BOOK_TOOLS, BOOK_QUARTO, BOOK_DEPS | |
| # | |
| # Related: | |
| # - infra-container-windows.yml — Windows container build (runs 2h after) | |
| # - infra-health-check.yml — Daily validation of built containers | |
| # - infra-cleanup-caches.yml — Weekly GHA cache cleanup | |
| # | |
| # ============================================================================= | |
| # Prevent multiple builds running simultaneously | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| # Default token scope for jobs without their own `permissions` (e.g. preflight). | |
| # The container build job overrides this with `packages: write` where needed. | |
| permissions: | |
| contents: read | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| force_rebuild: | |
| description: 'Force rebuild even if no changes' | |
| required: false | |
| default: false | |
| type: boolean | |
| no_cache: | |
| description: 'Disable Docker build cache (fresh build)' | |
| required: false | |
| default: false | |
| type: boolean | |
| container_registry: | |
| description: 'Container registry URL' | |
| required: false | |
| default: 'ghcr.io' | |
| type: string | |
| container_name: | |
| description: 'Container image name' | |
| required: false | |
| default: 'quarto-linux' | |
| type: string | |
| container_tag: | |
| description: 'Container tag' | |
| required: false | |
| default: 'latest' | |
| type: string | |
| workflow_call: | |
| inputs: | |
| force_rebuild: | |
| required: false | |
| default: false | |
| type: boolean | |
| no_cache: | |
| required: false | |
| default: false | |
| type: boolean | |
| container_registry: | |
| required: false | |
| default: 'ghcr.io' | |
| type: string | |
| container_name: | |
| required: false | |
| default: 'quarto-linux' | |
| type: string | |
| container_tag: | |
| required: false | |
| default: 'latest' | |
| type: string | |
| outputs: | |
| build-status: | |
| description: "Container build status (success/failure/skipped)" | |
| value: ${{ jobs.build.outputs.build-status }} | |
| image-name: | |
| description: "Full container image name with registry" | |
| value: ${{ jobs.build.outputs.image-name }} | |
| image-digest: | |
| description: "Container image digest (SHA256)" | |
| value: ${{ jobs.build.outputs.image-digest }} | |
| cache-hit: | |
| description: "Whether build used cache (true/false)" | |
| value: ${{ jobs.build.outputs.cache-hit }} | |
| # Re-enable automatic triggers | |
| schedule: | |
| - cron: '0 0 * * 0' # Weekly rebuild (Sunday at midnight) | |
| push: | |
| branches: [dev] # Only trigger on dev branch, not main | |
| paths: | |
| - 'book/tools/dependencies/**' | |
| - 'book/docker/linux/**' | |
| - '.github/workflows/infra-container-linux.yml' | |
| env: | |
| # ============================================================================= | |
| # PATH CONFIGURATION - Uses GitHub Repository Variables (Settings > Variables) | |
| # ============================================================================= | |
| # MLSysBook content lives under book/ to accommodate TinyTorch at root | |
| # Use ${{ vars.BOOK_ROOT }}, ${{ vars.BOOK_DOCKER }}, etc. in workflow steps | |
| # Variables: BOOK_ROOT, BOOK_DOCKER, BOOK_TOOLS, BOOK_QUARTO, BOOK_DEPS | |
| # Container Registry Configuration (configurable via inputs) | |
| REGISTRY: ${{ (github.event_name == 'workflow_dispatch' && inputs.container_registry) || 'ghcr.io' }} | |
| IMAGE_NAME: ${{ github.repository }}/${{ (github.event_name == 'workflow_dispatch' && inputs.container_name) || 'quarto-linux' }} | |
| CONTAINER_TAG: ${{ (github.event_name == 'workflow_dispatch' && inputs.container_tag) || 'latest' }} | |
| # Container Build Configuration | |
| PLATFORM: linux/amd64 | |
| # Using vars.BOOK_DOCKER (repository variable) - works in all contexts | |
| DOCKERFILE_PATH: ./${{ vars.BOOK_DOCKER }}/linux/Dockerfile | |
| CONTEXT_PATH: . | |
| jobs: | |
| preflight: | |
| name: 🌐 Preflight external URLs | |
| runs-on: ubuntu-latest | |
| if: github.repository_owner == 'harvard-edge' | |
| timeout-minutes: 5 | |
| steps: | |
| - name: 🔎 Probe required URLs and TL mirror pool | |
| env: | |
| QUARTO_VERSION: '1.9.27' | |
| TEXLIVE_REPOSITORY_URL: 'https://mirrors.mit.edu/CTAN/systems/texlive/tlnet' | |
| run: | | |
| set -uo pipefail | |
| # Required URLs — every one must return 200. These are the choke points | |
| # the container build hits before installing TeX Live; a dead one wastes | |
| # 30+ min on a runner before failing inside docker build. | |
| required=( | |
| "https://ppa.launchpadcontent.net/inkscape.dev/stable/ubuntu/dists/jammy/Release" | |
| "https://cloud.r-project.org/bin/linux/ubuntu/marutter_pubkey.asc" | |
| "https://cloud.r-project.org/bin/linux/ubuntu/jammy-cran40/InRelease" | |
| "https://github.com/quarto-dev/quarto-cli/releases/download/v${QUARTO_VERSION}/quarto-${QUARTO_VERSION}-linux-amd64.deb" | |
| "${TEXLIVE_REPOSITORY_URL}/tlpkg/texlive.tlpdb" | |
| ) | |
| # TeX Live install-tl mirror pool — install-texlive-base.sh tries each | |
| # in order and falls through on failure, so this is resilience not a | |
| # hard gate. Require ≥3 of 5 alive. | |
| mirrors=( | |
| "https://mirrors.mit.edu/CTAN/systems/texlive/tlnet/install-tl-unx.tar.gz" | |
| "https://ctan.math.washington.edu/tex-archive/systems/texlive/tlnet/install-tl-unx.tar.gz" | |
| "https://mirrors.rit.edu/CTAN/systems/texlive/tlnet/install-tl-unx.tar.gz" | |
| "https://mirror.las.iastate.edu/tex-archive/systems/texlive/tlnet/install-tl-unx.tar.gz" | |
| "https://mirror.ctan.org/systems/texlive/tlnet/install-tl-unx.tar.gz" | |
| ) | |
| probe() { | |
| url="$1" | |
| code=$(curl -sS -o /dev/null -w '%{http_code}' -m 15 -L --retry 1 "$url" 2>/dev/null || echo "ERR") | |
| printf '%s\t%s\n' "$code" "$url" | |
| } | |
| export -f probe | |
| echo "=== Required URLs (all must return 200) ===" | |
| fail=0 | |
| tmp=$(mktemp) | |
| printf '%s\n' "${required[@]}" | xargs -P 8 -I{} bash -c 'probe "$@"' _ {} > "$tmp" | |
| while IFS=$'\t' read -r code url; do | |
| if [ "$code" = "200" ]; then | |
| echo " ✓ $code $url" | |
| else | |
| echo " ✗ $code $url" | |
| fail=$((fail+1)) | |
| fi | |
| done < "$tmp" | |
| echo | |
| echo "=== TL install-tl mirror pool (need ≥3 of ${#mirrors[@]} alive) ===" | |
| alive=0 | |
| tmp2=$(mktemp) | |
| printf '%s\n' "${mirrors[@]}" | xargs -P 8 -I{} bash -c 'probe "$@"' _ {} > "$tmp2" | |
| while IFS=$'\t' read -r code url; do | |
| if [ "$code" = "200" ]; then | |
| echo " ✓ $code $url" | |
| alive=$((alive+1)) | |
| else | |
| echo " ⚠ $code $url" | |
| fi | |
| done < "$tmp2" | |
| echo | |
| if [ "$fail" -gt 0 ]; then | |
| echo "❌ $fail required URL(s) failed — aborting before container build" | |
| exit 1 | |
| fi | |
| if [ "$alive" -lt 3 ]; then | |
| echo "❌ Only $alive of ${#mirrors[@]} TL mirrors alive (need ≥3) — aborting" | |
| exit 1 | |
| fi | |
| echo "✅ Preflight passed: required URLs all up, $alive/${#mirrors[@]} TL mirrors alive" | |
| build: | |
| needs: preflight | |
| runs-on: ubuntu-latest | |
| if: github.repository_owner == 'harvard-edge' | |
| timeout-minutes: 90 # 1.5 hour timeout for Linux builds | |
| permissions: | |
| contents: read | |
| packages: write | |
| outputs: | |
| build-status: ${{ steps.build-summary.outputs.build-status }} | |
| image-name: ${{ steps.build-summary.outputs.image-name }} | |
| image-digest: ${{ steps.build-summary.outputs.image-digest }} | |
| cache-hit: ${{ steps.build-summary.outputs.cache-hit }} | |
| steps: | |
| - name: 🔍 Check workflow environment | |
| run: | | |
| set -euo pipefail # Exit immediately on any error | |
| echo "🔍 Checking workflow environment..." | |
| echo "📊 Repository: ${{ github.repository }}" | |
| echo "📊 Actor: ${{ github.actor }}" | |
| echo "📊 Event: ${{ github.event_name }}" | |
| echo "📊 Ref: ${{ github.ref }}" | |
| echo "📊 SHA: ${{ github.sha }}" | |
| echo "📊 Workflow: ${{ github.workflow }}" | |
| echo "📊 Run ID: ${{ github.run_id }}" | |
| echo "📊 Run Number: ${{ github.run_number }}" | |
| # Check if we have the required permissions | |
| echo "🔍 Checking permissions..." | |
| if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then | |
| echo "✅ Manual workflow dispatch - should have full permissions" | |
| elif [ "${{ github.event_name }}" = "push" ]; then | |
| echo "✅ Push event - should have full permissions" | |
| elif [ "${{ github.event_name }}" = "schedule" ]; then | |
| echo "✅ Scheduled event - should have full permissions" | |
| else | |
| echo "⚠️ Unknown event type: ${{ github.event_name }}" | |
| fi | |
| # Check if secrets are available | |
| echo "🔍 Checking secrets availability..." | |
| if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then | |
| echo "✅ GITHUB_TOKEN is available" | |
| else | |
| echo "❌ GITHUB_TOKEN is not available" | |
| exit 1 | |
| fi | |
| echo "✅ Environment check completed" | |
| - name: 📥 Checkout repository | |
| uses: actions/checkout@v6 | |
| - name: 🧹 Free up disk space | |
| run: | | |
| echo "🧹 Freeing up disk space for large container build..." | |
| echo "📊 Disk space before cleanup:" | |
| df -h / | |
| # Remove unnecessary packages and files | |
| sudo rm -rf /usr/share/dotnet | |
| sudo rm -rf /usr/local/lib/android | |
| sudo rm -rf /opt/ghc | |
| sudo rm -rf /opt/hostedtoolcache/CodeQL | |
| sudo docker system prune -af | |
| echo "📊 Disk space after cleanup:" | |
| df -h / | |
| echo "✅ Disk cleanup complete" | |
| - name: 🐳 Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v4 | |
| with: | |
| driver: docker-container | |
| driver-opts: | | |
| network=host | |
| buildkitd-flags: | | |
| --allow-insecure-entitlement security.insecure | |
| --allow-insecure-entitlement network.host | |
| buildkitd-config-inline: | | |
| [worker.oci] | |
| max-parallelism = 1 | |
| [registry."docker.io"] | |
| mirrors = ["mirror.gcr.io"] | |
| - name: 🔍 Verify Buildx Builder | |
| run: | | |
| echo "🔍 Checking buildx builder status..." | |
| docker buildx ls | |
| echo "🔍 Inspecting builder..." | |
| docker buildx inspect --bootstrap | |
| echo "✅ Buildx builder ready" | |
| - name: 🔐 Log in to Container Registry | |
| id: login | |
| uses: docker/login-action@v4 | |
| with: | |
| registry: ${{ env.REGISTRY }} | |
| username: ${{ github.actor }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: 🔍 Check registry access | |
| run: | | |
| set -euo pipefail # Exit immediately on any error | |
| echo "🔍 Checking container registry access..." | |
| echo "📊 Registry: ${{ env.REGISTRY }}" | |
| echo "📊 Repository: ${{ github.repository }}" | |
| echo "📊 Actor: ${{ github.actor }}" | |
| echo "📊 Event: ${{ github.event_name }}" | |
| # Test if we can access the registry | |
| echo "🔍 Testing Docker daemon access..." | |
| if docker info >/dev/null 2>&1; then | |
| echo "✅ Docker daemon is accessible" | |
| else | |
| echo "❌ Docker daemon not accessible" | |
| exit 1 | |
| fi | |
| # Test registry login with detailed error checking | |
| echo "🔍 Testing container registry login..." | |
| if echo "${{ secrets.GITHUB_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ github.actor }} --password-stdin; then | |
| echo "✅ Successfully logged into container registry" | |
| else | |
| echo "❌ Failed to log into container registry" | |
| echo "🔍 This could be due to:" | |
| echo " - Missing GITHUB_TOKEN secret" | |
| echo " - Insufficient permissions" | |
| echo " - Registry access issues" | |
| echo "🔍 Checking GITHUB_TOKEN availability..." | |
| if [ -n "${{ secrets.GITHUB_TOKEN }}" ]; then | |
| echo "✅ GITHUB_TOKEN is available" | |
| else | |
| echo "❌ GITHUB_TOKEN is empty or not available" | |
| fi | |
| exit 1 | |
| fi | |
| # Verify we can actually push to the registry | |
| echo "🔍 Testing registry write permissions..." | |
| TEST_IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/test-push:latest" | |
| if docker pull hello-world:latest >/dev/null 2>&1; then | |
| if docker tag hello-world:latest $TEST_IMAGE >/dev/null 2>&1; then | |
| if docker push $TEST_IMAGE >/dev/null 2>&1; then | |
| echo "✅ Registry write permissions confirmed" | |
| # Clean up test image | |
| docker rmi $TEST_IMAGE >/dev/null 2>&1 || true | |
| else | |
| echo "❌ Registry write permissions failed" | |
| exit 1 | |
| fi | |
| else | |
| echo "❌ Failed to tag test image" | |
| exit 1 | |
| fi | |
| else | |
| echo "❌ Failed to pull hello-world image for testing" | |
| exit 1 | |
| fi | |
| - name: 🏷️ Extract metadata | |
| id: meta | |
| uses: docker/metadata-action@v6 | |
| with: | |
| images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} | |
| tags: | | |
| type=raw,value=${{ env.CONTAINER_TAG }} | |
| - name: 🐳 Build Linux container | |
| id: build | |
| uses: docker/build-push-action@v7 | |
| with: | |
| context: ${{ env.CONTEXT_PATH }} | |
| file: ${{ env.DOCKERFILE_PATH }} | |
| load: true # Keep local copy for testing | |
| push: true # Also push to registry | |
| tags: ${{ steps.meta.outputs.tags }} | |
| labels: ${{ steps.meta.outputs.labels }} | |
| no-cache: ${{ github.event_name == 'workflow_dispatch' && inputs.no_cache || false }} # Use cache unless explicitly disabled | |
| platforms: ${{ env.PLATFORM }} | |
| provenance: false # Disable provenance for better compatibility | |
| sbom: false # Disable SBOM for better compatibility | |
| cache-from: ${{ (github.event_name != 'workflow_dispatch' || !inputs.no_cache) && 'type=gha' || '' }} | |
| cache-to: ${{ (github.event_name != 'workflow_dispatch' || !inputs.no_cache) && 'type=gha,mode=max' || '' }} | |
| outputs: type=docker | |
| - name: Build Complete | |
| run: | | |
| echo "✅ Linux container build completed successfully!" | |
| echo "📊 Container: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.CONTAINER_TAG }}" | |
| - name: 📊 Build Summary | |
| id: build-summary | |
| if: always() | |
| run: | | |
| # Determine build status | |
| if [ "${{ steps.build.outcome }}" = "success" ]; then | |
| BUILD_STATUS="success" | |
| else | |
| BUILD_STATUS="failure" | |
| fi | |
| # Extract build information | |
| IMAGE_NAME="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.CONTAINER_TAG }}" | |
| IMAGE_DIGEST="${{ steps.build.outputs.digest }}" | |
| CACHE_HIT="${{ steps.build.outputs.cache-hit }}" | |
| echo "build-status=$BUILD_STATUS" >> $GITHUB_OUTPUT | |
| echo "image-name=$IMAGE_NAME" >> $GITHUB_OUTPUT | |
| echo "image-digest=$IMAGE_DIGEST" >> $GITHUB_OUTPUT | |
| echo "cache-hit=$CACHE_HIT" >> $GITHUB_OUTPUT | |
| echo "📊 Build Status: $BUILD_STATUS" | |
| echo "🐳 Image: $IMAGE_NAME" | |
| echo "🔍 Digest: $IMAGE_DIGEST" | |
| echo "💾 Cache Hit: $CACHE_HIT" |