Build and Push

The Build and Push Job (build-and-push) serves as Job 1 in the automated CI/CD pipeline (.github/workflows/deploy.yml). Its core function is Continuous Integration (CI)—offloading resource-heavy compilation, dependency installation, and container packaging from your local machine and homelab server onto GitHub's cloud infrastructure.


  1. Execution Environment & Job Permissions

  2. Cloud Runner (runs-on: ubuntu-latest): The job executes on an ephemeral, cloud-hosted Virtual Machine managed by GitHub. Running the build phase in the cloud saves CPU cycles, RAM, and disk wear on local homelab hardware.

  3. Job Permissions:
  4. This enforces the principle of least privilege: contents: read grants the runner read-only access to download the repository code, while packages: write grants write access to publish built images to GitHub Container Registry (GHCR).
  5. Environment Variables & Expression Syntax (${{ ... }}):
    • REGISTRY: ghcr.io defines the target container registry domain.
    • IMAGE_NAME: ${{ github.repository }} dynamically evaluates to username/repo-name (e.g., ritikkumar27/astro_portfolio).
    • The double curly braces ${{ ... }} represent GitHub Actions expression syntax, instructing the runner to evaluate variables, secrets, and context objects at runtime rather than treating them as plain text.

  1. Step-by-Step Workflow Execution

Step 1: Checkout Repository (actions/checkout@v4)

  • Blank Slate Initialization: The ubuntu-latest runner provisions as a completely empty VM.
  • Code Download: actions/checkout@v4 pulls a full copy of your source code (including Dockerfile, .dockerignore, package.json, and the src/ folder) into the runner's workspace.
  • Version Locking (@v4): Pinning the action to version v4 prevents breaking workflow changes if GitHub releases future major updates.

Step 2: Log in to GitHub Container Registry (docker/login-action@v3)

  • Authentication: Authenticates the cloud runner against ghcr.io using docker/login-action@v3.
  • Dynamic Credentials:
    • username: ${{ github.actor }} fetches the handle of the user who triggered the push.
    • password: ${{ secrets.GITHUB_TOKEN }} uses an automated, short-lived token generated natively by GitHub for that specific pipeline run. This avoids hardcoding permanent Personal Access Tokens (PATs) or plain-text passwords into repository files.

Step 3: Set up Docker Buildx (docker/setup-buildx-action@v3)

  • BuildKit Engine: Initializes Docker Buildx, unlocking advanced container building capabilities, parallel layer execution, and multi-stage build optimizations.

Step 4: Multi-Stage Docker Compilation inside the Runner

The runner executes the multi-stage Dockerfile:

  1. Dependency Layer Caching: Copies package.json and package-lock.json (or pnpm-lock.yaml) first and runs npm ci (or pnpm install --frozen-lockfile). This creates an isolated cache layer for node_modules.
  2. Source Copy & Build: Executes COPY . . followed by RUN npm run build (or pnpm run build), invoking Astro to process templates, Tailwind CSS, DaisyUI, and content collections into static HTML/CSS/JS inside /app/dist.
  3. Build-Time Error Protections:
    • Missing Collections Crash: If a content collection directory (like store) defined in config.ts or routes is missing or empty, the @astrojs/sitemap integration crashes during static generation (Cannot read properties of undefined (reading 'reduce')). To ensure Job 1 passes, sitemap() is either removed from astro.config.mjs or empty collection directories are created with valid files.
    • RSS Method Capitalization: Astro API routes like src/pages/rss.xml.js require uppercase export handlers (export async function GET) to avoid router build warnings.
  4. Stage 2 Handoff: Extracts /app/dist into a lightweight caddy:alpine (or nginx:alpine) image, discarding Node.js and all development dependencies entirely.

Step 5: Dual-Tagging & Image Push (docker/build-push-action@v5)

  • Dual-Tagging Strategy: The job tags the compiled container image with two distinct references:
    • :latest: Used by the downstream homelab deployment script to pull the newest release.
    • :${{ github.sha }}: Pins the image to the exact 40-character Git commit hash, creating an immutable audit trail for version tracking and rollback capability.
  • Registry Upload: Pushes both tagged layers up to ghcr.io/ritikkumar27/astro_portfolio.

  1. Package Linking & GHCR Permissions Architecture

Automatic Repository Linking vs. Manual Pushes

  • Automatic Linking: When Job 1 pushes an image using ${{ secrets.GITHUB_TOKEN }}, GitHub recognizes that the token originated from that specific code repository. It automatically links the container package directly to the repository dashboard sidebar.
  • Manual Push Disconnect: Manual pushes from a laptop terminal using a Personal Access Token (PAT) land under the personal account's global "Packages" tab as unlinked packages. To link manual pushes without GitHub Actions, developers must manually click "Connect repository" in the UI or add LABEL org.opencontainers.image.source="https://github.com/username/repo" to the Dockerfile.

Resolving the 403 Forbidden Push Error

If an image was initially built and pushed manually from a laptop, GitHub assigns package ownership strictly to the individual user profile. If a subsequent GitHub Actions pipeline attempts to overwrite that image, the build will fail at the final step with a 403 Forbidden error.

To allow the automated pipeline to overwrite or update the package:

  1. Navigate to GitHub ProfilePackagesastro_portfolioPackage Settings.
  2. Under Manage Actions Access, click Add Repository and select the repository.
  3. Change the access role from Read to Write or Admin.

  1. Handoff to Job 2 (deploy)

The build-and-push job acts as a strict dependency gatekeeper for the deployment phase:

deploy:
  needs: build-and-push

The self-hosted homelab runner will never attempt to pull an image or restart container services unless Job 1 completes with a zero exit code (0), guaranteeing that broken builds never hit production.