Build & Test Pipeline

How pull requests and pushes to main decide what to build and test. The pipeline is change-driven: a PR only runs jobs for the apps it actually touches.

Units

A unit is a Docker Compose service that declares a build: section — one buildable image. Units are discovered by scanning every docker-compose.yml under stacks/ and images/; a service that pulls a stock image (e.g. postgres) has no build: section, so it is never a unit and is never built or tested here.

Each unit declares which files it cares about with an x-homelab.watch list, defaulting to its own stack directory:

services:
  fiber:
    build: ...
    x-homelab:
      watch:
        - "stacks/apps/fiber/**"

Change Detection

The detect job diffs the PR against its base and asks ci affected (in tools/ci/) which units the change touched:

The result is a JSON matrix that fans out into the test and build jobs, one entry per affected image.

Job Flow

graph LR detect["detect
affected units"] --> test["test (matrix)
one job per unit"] test --> tgate["Test gate"] test --> build["build (matrix)
one job per unit"] build --> bgate["Build gate"]

Images are pinned to a released version for deployment, not :latest. Promoting :latest to a version tag happens in the separate release workflow (release.yml).

Tooling Changes Rebuild Everything

Because .github/workflows/build.yml and tools/ci/** are tooling globs, a PR that only changes the pipeline re-runs every unit's test and build. This is intentional: a broken change to the build logic should be caught against all apps before it merges, not discovered later when it silently breaks the next unrelated PR. Everyday app PRs stay narrow — only the touched app runs.