diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8fceaee..5d9e80c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,3 +74,64 @@ jobs: - name: Test run: go test -race -count=1 -tags=integration ./... + dockerfile-lint: + name: Dockerfile lint + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Task + uses: go-task/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Lint the Dockerfile + run: task lint:docker + + image: + name: Image (${{ matrix.target }}, ${{ matrix.runner.platform }}) + runs-on: ${{ matrix.runner.os }} + strategy: + fail-fast: false + matrix: + target: [binary, debian] + runner: + - os: ubuntu-24.04 + platform: linux/amd64 + - os: ubuntu-24.04-arm + platform: linux/arm64 + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Set up Buildx + uses: docker/setup-buildx-action@v4 + + - name: Build the runtime image + id: build + uses: specsnl/github-actions/build-image@2.4.3 + with: + platform: ${{ matrix.runner.platform }} + image-name: ghcr.io/specsnl/labelsync + dockerfile: Dockerfile + target: ${{ matrix.target }} + load: true + raw-tag: ci-${{ matrix.target }} + build-args: LABELSYNC_VERSION=ci-${{ github.sha }} + + - name: Set up bats + id: bats + uses: bats-core/bats-action@4.0.0 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Smoke test + env: + BATS_LIB_PATH: ${{ steps.bats.outputs.lib-path }} + IMAGE: ${{ steps.build.outputs.image }} + EXPECTED_VERSION: ci-${{ github.sha }} + TARGET: ${{ matrix.target }} + run: bats test/image.bats + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3712cf4..d12c7c6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,55 +40,65 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} - images: - name: Images (${{ matrix.package }}) - runs-on: ubuntu-24.04 + image: permissions: contents: read packages: write strategy: fail-fast: false matrix: - include: - - package: ghcr.io/specsnl/labelsync - target: binary - - package: ghcr.io/specsnl/labelsync/debian - target: debian - steps: - - name: Checkout - uses: actions/checkout@v7 + runner: + - os: ubuntu-24.04 + platform: linux/amd64 + - os: ubuntu-24.04-arm + platform: linux/arm64 + uses: specsnl/github-actions/.github/workflows/build-go-cli.yml@2.4.3 + with: + runs-on: ${{ matrix.runner.os }} + platform: ${{ matrix.runner.platform }} + image-name: ghcr.io/specsnl/labelsync + target: binary + version-build-arg: LABELSYNC_VERSION - - name: Derive tags and labels - id: meta - uses: docker/metadata-action@v6 - with: - images: ${{ matrix.package }} - tags: | - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}},enable=${{ !startsWith(github.ref, 'refs/tags/v0.') }} - flavor: latest=auto - - - name: Set up Buildx - uses: docker/setup-buildx-action@v4 + image-manifest: + needs: image + permissions: + contents: read + packages: write + uses: specsnl/github-actions/.github/workflows/merge-go-cli.yml@2.4.3 + with: + runs-on: ubuntu-24.04 + image-name: ghcr.io/specsnl/labelsync + target: binary - - name: Log in to GHCR - uses: docker/login-action@v4 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + image-debian: + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + runner: + - os: ubuntu-24.04 + platform: linux/amd64 + - os: ubuntu-24.04-arm + platform: linux/arm64 + uses: specsnl/github-actions/.github/workflows/build-go-cli.yml@2.4.3 + with: + runs-on: ${{ matrix.runner.os }} + platform: ${{ matrix.runner.platform }} + image-name: ghcr.io/specsnl/labelsync + target: debian + version-build-arg: LABELSYNC_VERSION - - name: Build and push - uses: docker/build-push-action@v7 - with: - context: . - target: ${{ matrix.target }} - platforms: linux/amd64,linux/arm64 - push: true - provenance: false - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - annotations: ${{ steps.meta.outputs.annotations }} - build-args: | - LABELSYNC_VERSION=${{ steps.meta.outputs.version }} + image-debian-manifest: + needs: image-debian + permissions: + contents: read + packages: write + uses: specsnl/github-actions/.github/workflows/merge-go-cli.yml@2.4.3 + with: + runs-on: ubuntu-24.04 + image-name: ghcr.io/specsnl/labelsync + target: debian + variant: debian diff --git a/.hadolint.yml b/.hadolint.yml new file mode 100644 index 0000000..f8db95c --- /dev/null +++ b/.hadolint.yml @@ -0,0 +1,6 @@ +# https://github.com/hadolint/hadolint#configure +ignored: + # Pinning every apt/apk package on top of an already pinned base image trades a reproducible + # build for one that breaks the moment the distro moves a package version out from under it. + - DL3008 # Pin versions in apt-get install + - DL3018 # Pin versions in apk add diff --git a/AGENTS.md b/AGENTS.md index 06e4903..b4020b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,25 +20,28 @@ in the output; it is not an error. Run `task --list` for the full set. The ones used most: -| Command | What it does | -|------------------------|-------------------------------------------------------------------| -| `task checkall` | The full check sequence: `tidy:check`, `lint`, `test`, `md:check` | -| `task tidy:check` | `go mod tidy -diff` — fails if `go.mod`/`go.sum` are untidy | -| `task tidy` | `go mod tidy` | -| `task lint` | `golangci-lint run` | -| `task lint:fix` | `golangci-lint run --fix` | -| `task test` | `go test -race -tags=integration ./...` | -| `task test:update` | Rewrite the golden files from the current output | -| `task md:check` | markdownlint over every Markdown file | -| `task md:fix` | Align Markdown tables, then apply autofixable rules | -| `task build` | Build the binary into the working directory | -| `task docs:serve` | Hugo dev server with live reload on | -| `task docs:preview` | Build, then serve the static site over nginx on port 8080 | -| `task docs:build` | Build the site into `docs/public/` | -| `task docs:mod:tidy` | Tidy the Hugo module in `docs/` | -| `task release:dry-run` | Local goreleaser snapshot, no publishing | -| `task demo:record:*` | Re-record one demo GIF with VHS: `:labelsync`, `:init` | -| `task dc:shell` | Shell into the `go-builder` service | +| Command | What it does | +|------------------------|----------------------------------------------------------------------------------| +| `task checkall` | The full check sequence: `tidy:check`, `lint`, `lint:docker`, `test`, `md:check` | +| `task tidy:check` | `go mod tidy -diff` — fails if `go.mod`/`go.sum` are untidy | +| `task tidy` | `go mod tidy` | +| `task lint` | `golangci-lint run` | +| `task lint:fix` | `golangci-lint run --fix` | +| `task lint:docker` | `hadolint Dockerfile` | +| `task test` | `go test -race -tags=integration ./...` | +| `task test:update` | Rewrite the golden files from the current output | +| `task md:check` | markdownlint over every Markdown file | +| `task md:fix` | Align Markdown tables, then apply autofixable rules | +| `task build` | Build the binary into the working directory | +| `task image:build` | Load both runtime images locally: `:dev` and `:dev-debian` | +| `task image:smoke` | Build them, then run `test/image.bats` — what CI's image guard does | +| `task docs:serve` | Hugo dev server with live reload on | +| `task docs:preview` | Build, then serve the static site over nginx on port 8080 | +| `task docs:build` | Build the site into `docs/public/` | +| `task docs:mod:tidy` | Tidy the Hugo module in `docs/` | +| `task release:dry-run` | Local goreleaser snapshot, no publishing | +| `task demo:record:*` | Re-record one demo GIF with VHS: `:labelsync`, `:init` | +| `task dc:shell` | Shell into the `go-builder` service | ### Local check sequence @@ -48,13 +51,15 @@ Before opening a pull request, run: task checkall ``` -That is exactly `task tidy:check`, then `task lint`, then `task test`, then `task md:check`, in -that order — run them individually while iterating, and `checkall` before pushing. +That is exactly `task tidy:check`, then `task lint`, then `task lint:docker`, then `task test`, then +`task md:check`, in that order — run them individually while iterating, and `checkall` before +pushing. Every step of the sequence reports; none of them writes. `tidy:check` runs `go mod tidy -diff`, so an untidy `go.mod`/`go.sum` fails the check with the diff it would have applied rather than quietly rewriting the tree mid-check. Run `task tidy` to apply it. CI runs the same check in the `Unit -tests` job. +tests` job, and `lint:docker` — the task itself, so the hadolint version stays pinned once, in +`compose.yml` — in the `Dockerfile lint` job. `task build` runs `task lint` first, so a green build implies a green lint — but it does not run the tests or the Markdown checks. @@ -120,6 +125,11 @@ tests or the Markdown checks. or the developer experience, not by reflex. Prefer table-driven tests; use `net/http/httptest` for the GitHub client and an injected clock for anything time-dependent. + The one exception is [test/image.bats](./test/image.bats), which drives `docker run` against the + published images: the subject is a container, not a package, and bats is what the shared CI action + expects. It runs from `task image:smoke` and from the `Image (...)` jobs in CI, never from + `go test`. + - **Sentinel errors are always wrapped with `%w`.** Every way a run can fail has a sentinel in [internal/labelsync/errors.go](./internal/labelsync/errors.go). A call site with context to add never returns a sentinel bare, and never renders one with `%v` or into a freshly constructed @@ -148,6 +158,7 @@ tests or the Markdown checks. ```text labelsync/ ├── main.go # XDG init, cmd.Execute() +├── test/ # image.bats — acceptance checks for the published images └── internal/ ├── labelsync/ # configuration.go (XDG paths), errors.go (sentinels + KindOf) ├── cmd/ # one file per Cobra command diff --git a/Dockerfile b/Dockerfile index 71db8cf..cc5c86d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,8 @@ RUN apt-get update \ ca-certificates \ tree \ git \ - openssh-client + openssh-client \ + && rm -rf /var/lib/apt/lists/* FROM base AS builder-download @@ -40,6 +41,44 @@ RUN --mount=type=cache,target=/go/pkg/mod \ -tags netgo \ -ldflags "-s -w -X ${GO_MODULE}/internal/cmd.Version=${LABELSYNC_VERSION}" -o ./labelsync +# Latest version: https://hub.docker.com/r/bats/bats/tags +FROM bats/bats:1.14.0 AS bats + +ARG TARGETARCH + +# Latest version: https://download.docker.com/linux/static/stable/ +ARG DOCKER_VERSION=29.8.0 +# Latest version: https://github.com/bats-core/bats-support/releases/latest +ARG BATS_SUPPORT_VERSION=0.3.0 +# Latest version: https://github.com/bats-core/bats-assert/releases/latest +ARG BATS_ASSERT_VERSION=2.2.4 + +# busybox ash, since this stage is Alpine and carries no bash. +SHELL ["/bin/ash", "-o", "pipefail", "-c"] + +RUN apk add --no-cache \ + curl \ + tar + +RUN set -eux; \ + case "${TARGETARCH}" in \ + amd64) altarch=x86_64 ;; \ + arm64) altarch=aarch64 ;; \ + *) echo "unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \ + esac; \ + curl --fail --silent --show-error --location \ + "https://download.docker.com/linux/static/stable/${altarch}/docker-${DOCKER_VERSION}.tgz" \ + | tar --extract --gzip --directory /usr/bin --strip-components=1 docker/docker; \ + for spec in "support:${BATS_SUPPORT_VERSION}" "assert:${BATS_ASSERT_VERSION}"; do \ + name="bats-${spec%%:*}"; \ + mkdir -p "/usr/lib/bats/${name}"; \ + curl --fail --silent --show-error --location \ + "https://github.com/bats-core/${name}/archive/refs/tags/v${spec#*:}.tar.gz" \ + | tar --extract --gzip --directory "/usr/lib/bats/${name}" --strip-components=1; \ + done + +ENV BATS_LIB_PATH=/usr/lib/bats + # Latest version: https://hub.docker.com/_/debian/tags FROM debian:13.6-slim AS debian diff --git a/README.md b/README.md index d41b596..3bdc5f2 100644 --- a/README.md +++ b/README.md @@ -75,8 +75,8 @@ Or `go install github.com/specsnl/labelsync@latest`, or download a `tar.gz` for the [releases page](https://github.com/specsnl/labelsync/releases) — Linux and macOS, amd64 and arm64. -In a container, `docker run --rm ghcr.io/specsnl/labelsync:0.1 --help` — also published as -`ghcr.io/specsnl/labelsync/debian` for when a step needs a shell. +In a container, `docker run --rm ghcr.io/specsnl/labelsync:0.1 --help` — with a `:0.1-debian` variant +of every tag for when a step needs a shell. Release candidates are a separate, opt-in cask, so `brew upgrade` never moves a stable install onto one — while the rc cask itself tracks the leading edge and upgrades onto a stable release once it diff --git a/Taskfile.dist.yml b/Taskfile.dist.yml index 468b5e7..50eb8dc 100644 --- a/Taskfile.dist.yml +++ b/Taskfile.dist.yml @@ -4,6 +4,7 @@ version: "3" includes: demo: ./taskfiles/Taskfile.demo.yml docs: ./taskfiles/Taskfile.docs.yml + image: ./taskfiles/Taskfile.image.yml md: ./taskfiles/Taskfile.md.yml lint: taskfile: ./taskfiles/Taskfile.lint.yml @@ -83,42 +84,6 @@ tasks: --set "go-binary.args.LABELSYNC_VERSION={{.LABELSYNC_VERSION}}" go-binary - images: - desc: Build both container images locally, tagged :dev - summary: | - Builds the two images the release workflow publishes, from the same - Dockerfile stages, and loads them into the local docker: - - ghcr.io/specsnl/labelsync:dev the scratch image, `binary` stage - ghcr.io/specsnl/labelsync/debian:dev the debian image, `debian` stage - - `task release:dry-run` cannot do this: the goreleaser service has no - docker socket, and the images do not go through goreleaser at all. - - Host platform only. --load writes into the docker image store, which holds - one platform per tag, so the multi-platform manifest list is the one part - of the published result that only a real release produces. - cmds: - - task: image - vars: { TARGET: binary, IMAGE: "ghcr.io/specsnl/labelsync" } - - task: image - vars: { TARGET: debian, IMAGE: "ghcr.io/specsnl/labelsync/debian" } - - docker run --rm ghcr.io/specsnl/labelsync:dev version - - docker run --rm ghcr.io/specsnl/labelsync/debian:dev version - - image: - desc: Build one container image locally - internal: true - cmds: - - >- - docker buildx build . - --target {{ .TARGET }} - --tag {{ .IMAGE }}:dev - --build-arg LABELSYNC_VERSION={{ .LABELSYNC_VERSION }} - --load - requires: - vars: [TARGET, IMAGE] - tidy:check: desc: Check that go.mod and go.sum are tidy summary: | @@ -168,14 +133,15 @@ tasks: bash -c 'go test $(grep -rlF --include="*_test.go" "flag.Bool(\"update\"" . | xargs -n1 dirname | sort -u) -update' checkall: - desc: Run every check — the tidy check, golangci-lint, the Go tests, and markdownlint + desc: Run every check — the tidy check, golangci-lint, hadolint, the Go tests, and markdownlint summary: | - The full local check sequence: tidy:check, lint, test, md:check. + The full local check sequence: tidy:check, lint, lint:docker, test, md:check. Every step reports; none of them writes. Run this before opening a pull request. cmds: - task: tidy:check - task: lint + - task: lint:docker - task: test - task: md:check diff --git a/ci_test.go b/ci_test.go new file mode 100644 index 0000000..1b1a5a7 --- /dev/null +++ b/ci_test.go @@ -0,0 +1,161 @@ +package main + +import ( + "fmt" + "maps" + "os" + "slices" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +// ciWorkflowPath is the workflow whose `image` job is the only thing between a +// broken Dockerfile and a tag being cut with it. +const ciWorkflowPath = ".github/workflows/ci.yml" + +// batsPath is the acceptance script the guard runs, and the one `task +// image:smoke` runs against the same images locally. +const batsPath = "test/image.bats" + +// The CI workflow, in the shape these tests read it. As elsewhere, the fields +// left out are the ones nothing is asserted about. +type ciWorkflow struct { + Jobs map[string]struct { + RunsOn string `yaml:"runs-on"` + Strategy struct { + Matrix struct { + Target []string `yaml:"target"` + Runner []matrixRunner `yaml:"runner"` + } `yaml:"matrix"` + } `yaml:"strategy"` + Steps []ciStep `yaml:"steps"` + } `yaml:"jobs"` +} + +type ciStep struct { + Uses string `yaml:"uses"` + Run string `yaml:"run"` + Env map[string]string `yaml:"env"` + With map[string]any `yaml:"with"` +} + +func loadCIWorkflow(t *testing.T) ciWorkflow { + t.Helper() + + content, err := os.ReadFile(ciWorkflowPath) + if err != nil { + t.Fatalf("read %s: %v", ciWorkflowPath, err) + } + + var workflow ciWorkflow + if err := yaml.Unmarshal(content, &workflow); err != nil { + t.Fatalf("parse %s: %v", ciWorkflowPath, err) + } + + if _, ok := workflow.Jobs["image"]; !ok { + t.Fatalf("%s has no `image` job; jobs are %v", ciWorkflowPath, slices.Sorted(maps.Keys(workflow.Jobs))) + } + + return workflow +} + +// step returns the first step of the image job whose `uses:` or `run:` contains +// needle. +func (w ciWorkflow) step(t *testing.T, needle string) ciStep { + t.Helper() + + for _, step := range w.Jobs["image"].Steps { + if strings.Contains(step.Uses, needle) || strings.Contains(step.Run, needle) { + return step + } + } + + t.Fatalf("the image job has no step naming %q", needle) + + return ciStep{} +} + +// A stage the guard does not build is a stage whose first run is a release. Both +// published stages are checked, on a runner of their own architecture, because +// the point is to *run* the binary: a build that only compiles the foreign +// architecture proves nothing about the image it produced. +func TestCI_ImageGuardCoversEveryPublishedStage(t *testing.T) { + job := loadCIWorkflow(t).Jobs["image"] + + want := slices.Sorted(maps.Values(publishedTargets)) + + if got := slices.Sorted(slices.Values(job.Strategy.Matrix.Target)); !slices.Equal(got, want) { + t.Errorf("the guard builds targets %v, want %v — the same stages the release publishes", got, want) + } + + runners := map[string]string{ + "linux/amd64": "ubuntu-24.04", + "linux/arm64": "ubuntu-24.04-arm", + } + + for _, runner := range job.Strategy.Matrix.Runner { + native, ok := runners[runner.Platform] + if !ok { + t.Errorf("the guard builds unexpected platform %q", runner.Platform) + + continue + } + + if runner.OS != native { + t.Errorf("the guard builds %s on %q, want %q — a foreign image cannot be run", + runner.Platform, runner.OS, native) + } + + delete(runners, runner.Platform) + } + + for platform := range runners { + t.Errorf("the guard does not build %s", platform) + } +} + +// The version build arg is the one input a rename fails silently on, and the +// guard exists to make that loud: it builds with a version no fallback could +// produce and the script asserts the binary reports exactly that. The two have +// to agree, or the guard fails on every run and gets deleted. +func TestCI_ImageGuardAssertsTheInjectedVersion(t *testing.T) { + workflow := loadCIWorkflow(t) + + buildArgs := fmt.Sprint(workflow.step(t, "specsnl/github-actions/build-image").With["build-args"]) + + version, ok := strings.CutPrefix(strings.TrimSpace(buildArgs), versionBuildArg+"=") + if !ok { + t.Fatalf("the build step passes build-args %q, want them to start with %s=", buildArgs, versionBuildArg) + } + + smoke := workflow.step(t, batsPath) + + if got := smoke.Env["EXPECTED_VERSION"]; got != version { + t.Errorf("the smoke step expects version %q, but the image is built with %q", got, version) + } + + if smoke.Env["TARGET"] == "" { + t.Error("the smoke step passes no TARGET, so the script cannot tell the scratch image from the debian one") + } +} + +// The script the guard runs is the same file `task image:smoke` runs, so a +// rename that only lands in one of them leaves the other calling a path that +// does not exist — which CI reports as a failing job and a developer reports as +// a broken task. +func TestCI_ImageGuardRunsTheCheckedInScript(t *testing.T) { + if _, err := os.Stat(batsPath); err != nil { + t.Fatalf("stat %s: %v", batsPath, err) + } + + taskfile, err := os.ReadFile("taskfiles/Taskfile.image.yml") + if err != nil { + t.Fatalf("read the image taskfile: %v", err) + } + + if !strings.Contains(string(taskfile), batsPath) { + t.Errorf("task image:smoke does not run %s, so the local checks and CI are two different suites", batsPath) + } +} diff --git a/compose.yml b/compose.yml index 085e405..fbacbeb 100644 --- a/compose.yml +++ b/compose.yml @@ -118,6 +118,41 @@ services: - .:/src - ./dev:/usr/local/bin:ro + bats: + profiles: ["image"] + user: ${FIXUID:-1000}:${FIXGID:-1000} + build: + context: . + dockerfile: Dockerfile + target: bats + working_dir: ${PWD} + environment: + HOME: /tmp + DOCKER_HOST: tcp://docker-socket-proxy:2375 + TMPDIR: /tmp/labelsync-image-smoke + depends_on: + - docker-socket-proxy + volumes: + - ${PWD}:${PWD} + - /tmp/labelsync-image-smoke:/tmp/labelsync-image-smoke + + docker-socket-proxy: + profiles: ["image"] + # Latest version: https://hub.docker.com/r/tecnativa/docker-socket-proxy/tags + image: tecnativa/docker-socket-proxy:v0.5.0 + environment: + ALLOW_START: 1 + ALLOW_STOP: 1 + CONTAINERS: 1 + DISTRIBUTION: 1 + IMAGES: 1 + INFO: 1 + NETWORKS: 1 + POST: 1 + VOLUMES: 1 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + node: profiles: ["markdown"] user: ${FIXUID:-1000}:${FIXGID:-1000} @@ -133,6 +168,15 @@ services: - .:/src - cache:/cache + hadolint: + profiles: ["lint"] + user: ${FIXUID:-1000}:${FIXGID:-1000} + # Latest version: https://hub.docker.com/r/hadolint/hadolint/tags + image: hadolint/hadolint:v2.14.0 + working_dir: /src + volumes: + - .:/src + golangci-lint: profiles: ["lint"] user: ${FIXUID:-1000}:${FIXGID:-1000} diff --git a/docs/content/docs/architecture/distribution.md b/docs/content/docs/architecture/distribution.md index 4e54be3..6247473 100644 --- a/docs/content/docs/architecture/distribution.md +++ b/docs/content/docs/architecture/distribution.md @@ -5,16 +5,17 @@ weight: 12 A release is one git tag. Pushing `v1.2.3` runs [`.github/workflows/release.yml`](https://github.com/specsnl/labelsync/blob/main/.github/workflows/release.yml), -which has two independent jobs: `release` runs goreleaser once — building every binary, creating the -GitHub release, and committing the cask *for that tag's channel* to -[`specsnl/homebrew-tap`](https://github.com/specsnl/homebrew-tap) — while `images` builds the two -container images and pushes them to GHCR. Neither needs anything from the other, so they run side by -side. Nothing else is manual, and there is no version to bump anywhere in the tree — -see [Versioning]({{< ref "./versioning.md" >}}). +which has two independent halves: `release` runs goreleaser once — building every binary, creating +the GitHub release, and committing the cask *for that tag's channel* to +[`specsnl/homebrew-tap`](https://github.com/specsnl/homebrew-tap) — while four `image*` jobs call the +organisation's shared image pipeline and push the container images to GHCR. Neither half needs +anything from the other, so they run side by side. Nothing else is manual, and there is no version to +bump anywhere in the tree — see [Versioning]({{< ref "./versioning.md" >}}). The workflow's own `permissions:` block is `contents: read`, and each job asks for the one write scope it needs: `contents: write` for the release, `packages: write` for the push to GHCR. Neither -job holds the other's token. +half holds the other's token, and a reusable workflow inherits nothing it is not granted, so the +`packages: write` sits on the calling job rather than at the top of the file. ## What a release produces @@ -30,12 +31,13 @@ Four archives, one per platform, plus `checksums.txt`: Every binary is `CGO_ENABLED=0` and `-tags=netgo`, so it is statically linked and depends on nothing on the target machine. `-trimpath` and `-s -w` keep build paths out of it and the symbol table small. -And two container images, each a manifest list over `linux/amd64` and `linux/arm64`: +And two container images — one package, two variants — each a manifest list over `linux/amd64` and +`linux/arm64`: -| Package | Base | Why it exists | -|------------------------------------|--------------------|-------------------------------------------------------------------| -| `ghcr.io/specsnl/labelsync` | `scratch` | The default. Binary, CA bundle, `/etc/passwd`, nothing else | -| `ghcr.io/specsnl/labelsync/debian` | `debian:13.6-slim` | Has a shell, so it can be a base image or a multi-command CI step | +| Reference | Base | Why it exists | +|------------------------------------------|--------------------|-------------------------------------------------------------------| +| `ghcr.io/specsnl/labelsync:1.2.3` | `scratch` | The default. Binary, CA bundle, `/etc/passwd`, nothing else | +| `ghcr.io/specsnl/labelsync:1.2.3-debian` | `debian:13.6-slim` | Has a shell, so it can be a base image or a multi-command CI step | ## The four channels @@ -129,13 +131,39 @@ release, at the last step, after the GitHub release has already been created. ## The container images Both images come out of the same -[`Dockerfile`](https://github.com/specsnl/labelsync/blob/main/Dockerfile) the local build uses: the -`images` job runs `docker/build-push-action` once per runtime stage, selecting it with `target:`. -`binary` is the `scratch` stage and publishes `ghcr.io/specsnl/labelsync`; `debian` publishes -`ghcr.io/specsnl/labelsync/debian`. +[`Dockerfile`](https://github.com/specsnl/labelsync/blob/main/Dockerfile) the local build uses, and +both are published by the organisation's shared pipeline in +[`specsnl/github-actions`](https://github.com/specsnl/github-actions) — `build-go-cli.yml` per +platform, then `merge-go-cli.yml` to merge the digests into a manifest list. The whole of it is four +jobs of configuration: -Three things about those stages are load-bearing, and none of them is visible in a `version` smoke -test: +```yaml + image: + strategy: + matrix: + runner: + - { os: ubuntu-24.04, platform: linux/amd64 } + - { os: ubuntu-24.04-arm, platform: linux/arm64 } + uses: specsnl/github-actions/.github/workflows/build-go-cli.yml@2.4.3 + with: + image-name: ghcr.io/specsnl/labelsync + target: binary + version-build-arg: LABELSYNC_VERSION +``` + +plus the same again for `target: debian`, and a manifest job per target — the debian one carrying +`variant: debian`. Login, buildx, the digest export, the tag policy, the OCI labels and +`provenance: false` all live in the shared workflow, along with a layer cache in the Actions cache +keyed per Dockerfile, platform and target. + +What stays here is what only this repository can know: **which stages to publish**, **what the image +is called**, and **what the version build arg is named**. Everything else the pipeline works out from +the tag. There is no `version:` input either — the shared workflow defaults it to the tag without its +leading `v`, which is exactly what goreleaser injects, so the image and the tarball cut from one tag +cannot disagree about what they are. + +Three things about the runtime stages are load-bearing, and none of them is visible in a `version` +smoke test: - **The CA bundle, copied into `/etc/ssl/certs/` — with the trailing slash.** Without it the file lands as a *file* named `/etc/ssl/certs`, and Go, which looks in six fixed paths and not that one, @@ -153,50 +181,57 @@ test: ### Tags -| Git tag | `ghcr.io/specsnl/labelsync` | `…/labelsync/debian` | -|---------------|-------------------------------|-------------------------------| -| `v1.2.3` | `1.2.3`, `1.2`, `1`, `latest` | `1.2.3`, `1.2`, `1`, `latest` | -| `v1.3.0-rc.1` | `1.3.0-rc.1` | `1.3.0-rc.1` | -| `v0.4.0` | `0.4.0`, `0.4`, `latest` | `0.4.0`, `0.4`, `latest` | - -`docker/metadata-action` owns that policy declaratively, which is what keeps the pre-release -behaviour and the moving tags as config rather than as hand-written conditionals over `github.ref`: - -```yaml -tags: | - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}},enable=${{ !startsWith(github.ref, 'refs/tags/v0.') }} -flavor: latest=auto -``` - -- **No `v` prefix.** `labelsync version` prints the tag without one — that is what goreleaser's - `{{ .Version }}` renders, and what `metadata-action`'s `version` output is — so a `:v1.2.3` tag - would disagree with the version the image reports. The images take that same value as the - `LABELSYNC_VERSION` build arg, which is how both build paths agree. -- **`1.2.3` is immutable; `1.2`, `1` and `latest` move.** All four come out of the *same* build in the - same push, so every tag of one release resolves to one manifest digest by construction — +| Git tag | Scratch | Debian | +|---------------|-----------------------------------------|---------------------------------------------------------------------| +| `v1.2.3` | `1.2.3`, `1.2`, `1`, `v1.2.3`, `latest` | `1.2.3-debian`, `1.2-debian`, `1-debian`, `v1.2.3-debian`, `debian` | +| `v1.3.0-rc.1` | `1.3.0-rc.1`, `v1.3.0-rc.1` | `1.3.0-rc.1-debian`, `v1.3.0-rc.1-debian` | +| `v0.4.0` | `0.4.0`, `0.4`, `v0.4.0`, `latest` | `0.4.0-debian`, `0.4-debian`, `v0.4.0-debian`, `debian` | + +**The debian image is a tag suffix, not a package of its own.** That is how `node`, `python` and +`postgres` publish their base-image variants, and it is what the organisation's shared pipeline is +built around: `variant: debian` on the merge job turns into a `-debian` suffix on every version tag +plus a bare `:debian`. The variants share a tag namespace and each still has its own digest. It cost +one thing to adopt — `ghcr.io/specsnl/labelsync/debian`, the nested package the first four release +candidates pushed to, is frozen at `0.1.0-rc.4` and receives nothing further. + +`docker/metadata-action`, inside the shared workflow, owns the policy declaratively, which is what +keeps the pre-release behaviour and the moving tags as config rather than as hand-written +conditionals over `github.ref`: + +- **The version tag has no `v`.** `labelsync version` prints the tag without one — that is what + goreleaser's `{{ .Version }}` renders, and what `metadata-action`'s `version` output is. `v1.2.3` + is published too, as an alias onto the same digest, because the shared pipeline also emits + `type=ref,event=tag`; nothing in labelsync reads it, and `:1.2.3` stays the form the documentation + and the CI recipe pin. +- **`1.2.3` is immutable; `1.2`, `1` and `latest` move.** They all come out of the *same* digests in + the same merge, so every tag of one release resolves to one manifest by construction — `docker buildx imagetools inspect` reports the same digest for each. Tagging `v1.2.4` re-points `:1.2` and `:1` at the new manifest. - **No `:0` while labelsync is pre-1.0.** Semver allows a `0.x` bump to break, so a `:0` tag would - promise stability across exactly the releases most likely to break. The `enable=` guard drops at + promise stability across exactly the releases most likely to break. The shared workflow guards the + bare `{{major}}` on `!startsWith(github.ref, 'refs/tags/v0.')`, so the guard drops by itself at 1.0.0; until then `:0.4` is the narrowest honest moving tag, and it still moves on every patch. -- **A pre-release moves nothing.** `metadata-action` emits neither `{{major}}` nor - `{{major}}.{{minor}}` for a prerelease semver tag, and `latest=auto` moves `latest` only for a - non-prerelease — so an `-rc.N` tag publishes its own version tag and touches nothing else. The - window between an rc and its stable release is exactly when `docker run ghcr.io/specsnl/labelsync` - must not hand a stranger a release candidate. It is the same problem the tap has, and the reason - that one ships [two casks](#stable-and-rc-are-two-casks) — but the cheaper answer to it: a tag - pattern, rather than a second package per channel. +- **A pre-release moves nothing.** `metadata-action` collapses `{{major}}` and `{{major}}.{{minor}}` + onto the full version for a prerelease semver tag, and the shared workflow sets `latest=false` and + re-adds `:latest` itself, guarded on a tag with no `-` in it — so an `-rc.N` tag publishes its own + version tag and touches nothing else. The window between an rc and its stable release is exactly + when `docker run ghcr.io/specsnl/labelsync` must not hand a stranger a release candidate. It is the + same problem the tap has, and the reason that one ships + [two casks](#stable-and-rc-are-two-casks) — but the cheaper answer to it: a tag pattern, rather + than a second package per channel. `org.opencontainers.image.source` is not decoration: without it GHCR does not link the package to the repository, and an unlinked package inherits neither its visibility nor its permissions. -`metadata-action` emits it alongside `.version`, `.revision`, `.licenses` and `.description`, and -they are passed on as both labels and manifest annotations. +`metadata-action` emits it alongside `.version`, `.revision`, `.licenses` and `.description`, and the +shared pipeline passes them on as both labels and manifest annotations. + +### One runner per platform -### Two platforms, no emulation +Each platform is built on a runner of its own architecture — `linux/amd64` on `ubuntu-24.04`, +`linux/arm64` on `ubuntu-24.04-arm` — and the two digests are merged into a manifest list afterwards. +Nothing is emulated, and nothing has to be: `setup-qemu-action` appears nowhere in the pipeline. -`platforms: linux/amd64,linux/arm64` is one build per image, and neither leg runs a foreign binary: +The Dockerfile could have supplied both legs from one runner, and still can: - the builder stage is `FROM --platform=$BUILDPLATFORM`, so `apt-get` and `go build` always run natively; @@ -205,12 +240,52 @@ they are passed on as both labels and manifest annotations. `task build` asks for the host's own platform; - both runtime stages only `COPY`. -That is why the job needs no `setup-qemu-action` at all. Letting buildx pick the target platform for -the *builder* instead would run the whole `apt-get` and `go build` under QEMU for the arm64 leg. - -`provenance: false` keeps the manifest list to the two platforms it claims; buildx otherwise attaches -provenance as extra `unknown/unknown` manifests, which several registries render as phantom -platforms. +That property is what makes `docker buildx build --platform linux/arm64` on an amd64 laptop finish in +seconds rather than crawling through QEMU, and `TestRelease_ImagesCrossCompileRatherThanEmulate` +keeps it. The release no longer depends on it, but the +[pull-request guard](#the-pull-request-guard) needs the native runners for a different reason +entirely: it *runs* what it builds. + +`provenance: false`, which the shared `build-image` action sets, keeps the manifest list to the two +platforms it claims; buildx otherwise attaches provenance as extra `unknown/unknown` manifests, which +several registries render as phantom platforms. + +### The pull-request guard + +A broken Dockerfile should fail on the pull request, not while a tag is being cut. The `image` job in +[`ci.yml`](https://github.com/specsnl/labelsync/blob/main/.github/workflows/ci.yml) builds both +stages on both architectures — four jobs — and runs +[`test/image.bats`](https://github.com/specsnl/labelsync/blob/main/test/image.bats) against each +result. + +It calls the shared `build-image` action directly rather than `build-go-cli.yml`, because the image +has to be built and run in the same job: a reusable workflow would load it into a daemon the caller +cannot reach. `load: true` builds one platform into the runner's own daemon and reports the reference +to run — which is also why each leg needs a runner of its own architecture. A build that only +compiles the foreign architecture proves nothing about the image it produced. + +What the script asserts is the set of things a release would otherwise be the first to find out: + +- **the version the binary reports.** The image is built with `LABELSYNC_VERSION=ci-`, a string + no fallback can produce, and the binary has to print it back. This is the one input that fails + *silently*: rename the build arg on either side and buildx warns about an unused arg, the build + succeeds, and the image reports `dev`. +- **that it runs as `65534`,** read off `Config.User` rather than from `id`, which the scratch image + has no shell to run. +- **that the CA bundle is at `/etc/ssl/certs/ca-certificates.crt`,** copied out of a created + container — again, no shell — so the missing trailing slash is caught here rather than by the first + API call someone makes. +- **that a bind-mounted config is readable and parses.** A valid one fails on the *token*, which is + how the script knows the YAML was read; an invalid one comes back with + `"error_kind":"invalid_color"`. +- **the shell in the debian image, and `/etc/passwd` in the scratch one** — the two properties that + belong to one stage each. + +The same script runs locally: `task image:smoke` builds both images and drives bats through a +container that reaches the host daemon over a socket proxy. + +The four `Image (...)` checks are not in the branch ruleset's required checks, so they report but do +not gate. ### Two build paths for one binary @@ -227,13 +302,12 @@ templates with manual `{{ if not .Prerelease }}` conditionals where `metadata-ac patterns; and nothing about it is verifiable locally while the `goreleaser` service has no docker socket. If the identical-bits property ever matters more than those three, that is the way back. -### The two manual steps +### The manual step -GHCR creates a package private on its first push, so each of the two needs its visibility flipped -once, by hand, before anyone can pull it. A nested name (`labelsync/debian`) is legal for an -organisation package and gets its own package page — the alternative, one package with a -`1.2.3-debian` tag suffix, halves that bookkeeping but makes the variants share a tag namespace and -hides the digest-per-variant distinction in the package list. +GHCR creates a package private on its first push, so its visibility has to be flipped once, by hand, +before anyone can pull it. That is one package now rather than two, which is the bookkeeping the +`-debian` suffix buys: `ghcr.io/specsnl/labelsync` is already public, and the variant lands inside +it. ## Verifying it without publishing @@ -259,18 +333,22 @@ Three things the local run cannot tell you, all because they only exist at publi are uploaded, whether `HOMEBREW_TAP_GITHUB_TOKEN` is present, and whether the tap accepts the commit. The images are not part of that: they do not go through goreleaser, and the `goreleaser` service has -no docker socket to build them with. `task images` is their local equivalent — it builds both stages, -tags them `:dev`, loads them into the local docker, and runs `version` out of each: +no docker socket to build them with. Two tasks cover them instead — `task image:build` loads both +stages into the local docker as `:dev` and `:dev-debian`, mirroring the published names, and +`task image:smoke` builds them and then runs the same `test/image.bats` the pull-request guard runs: + +```sh +task image:smoke +``` + +The one check neither makes is the CA bundle *working*, as opposed to being present — that needs the +network: ```sh -task images docker run --rm -e GH_TOKEN -v "$PWD/labels.yml:/labels.yml:ro" \ ghcr.io/specsnl/labelsync:dev sync --dry-run --config /labels.yml ``` -That second command is the check the `version` smoke test cannot make: it is the only one that proves -the CA bundle landed where Go looks for it. - Host platform only, because `--load` writes into the docker image store, which holds one platform per tag. The multi-platform manifest list is the one part of the published result that only a real release produces — `docker buildx build --platform linux/amd64,linux/arm64 --output type=cacheonly` proves diff --git a/docs/content/docs/usage/ci.md b/docs/content/docs/usage/ci.md index 6b4fe79..146292a 100644 --- a/docs/content/docs/usage/ci.md +++ b/docs/content/docs/usage/ci.md @@ -168,15 +168,16 @@ jobs: ### From a container image The alternative to installing a toolchain is pulling one. Two images are published to GHCR on every -release: +release, as one package with two variants: -| Image | Contents | -|------------------------------------|---------------------------------------------------------------------------| -| `ghcr.io/specsnl/labelsync` | `scratch` — the binary and a CA bundle, nothing else | -| `ghcr.io/specsnl/labelsync/debian` | `debian:13.6-slim` — has a shell, so a step can run more than one command | +| Image | Contents | +|----------------------------------------|---------------------------------------------------------------------------| +| `ghcr.io/specsnl/labelsync:0.1` | `scratch` — the binary and a CA bundle, nothing else | +| `ghcr.io/specsnl/labelsync:0.1-debian` | `debian:13.6-slim` — has a shell, so a step can run more than one command | -Both are manifest lists over `linux/amd64` and `linux/arm64`, and both run as uid `65534` with the -binary as their entrypoint, so arguments append: +The `-debian` suffix is on every tag the default variant has, plus a bare `:debian`. Both are +manifest lists over `linux/amd64` and `linux/arm64`, and both run as uid `65534` with the binary as +their entrypoint, so arguments append: ```yaml - name: Check for drift @@ -196,8 +197,9 @@ deliberately no `:0` while labelsync is pre-1.0, because semver lets a `0.x` bum on, `:1` becomes the tag to pin. `:latest` exists but is the wrong choice for CI, for the usual reason: it changes under you across a major version. -A pre-release publishes only its own exact tag — `:0.2.0-rc.1` — and moves neither `:latest` nor any -minor tag, so a pinned job never wakes up on a release candidate. +A pre-release publishes only its own version — `:0.2.0-rc.1`, and `:v0.2.0-rc.1` as an alias onto the +same digest — and moves neither `:latest` nor any minor tag, so a pinned job never wakes up on a +release candidate. The container needs the config file, which is why the working directory is mounted; `--config` takes a path if it lives somewhere else. Everything on this page applies unchanged — the exit codes are the diff --git a/release_test.go b/release_test.go index 9fe2527..213c2ae 100644 --- a/release_test.go +++ b/release_test.go @@ -204,24 +204,54 @@ func TestRelease_CaskClearsTheQuarantineAttribute(t *testing.T) { // deliberately: yaml.v3 parses the bare key as the boolean true, and nothing // here needs the trigger. type releaseWorkflow struct { + Permissions map[string]string `yaml:"permissions"` + Jobs map[string]releaseJob `yaml:"jobs"` +} + +// A job that calls a reusable workflow has `uses`/`with` where a normal one has +// `steps`, so both shapes live on one struct and each test reads the half it +// cares about. +type releaseJob struct { + Needs string `yaml:"needs"` + Uses string `yaml:"uses"` + With map[string]any `yaml:"with"` Permissions map[string]string `yaml:"permissions"` - Jobs map[string]struct { - Permissions map[string]string `yaml:"permissions"` - Strategy struct { - Matrix struct { - Include []struct { - Package string `yaml:"package"` - Target string `yaml:"target"` - } `yaml:"include"` - } `yaml:"matrix"` - } `yaml:"strategy"` - Steps []struct { - Uses string `yaml:"uses"` - With map[string]any `yaml:"with"` - } `yaml:"steps"` - } `yaml:"jobs"` + Strategy struct { + Matrix struct { + Runner []matrixRunner `yaml:"runner"` + } `yaml:"matrix"` + } `yaml:"strategy"` +} + +// matrixRunner is one leg of an image build: the runner it runs on and the +// platform it builds for. Shared with the pull-request guard in ci_test.go, +// which pairs them the same way. +type matrixRunner struct { + OS string `yaml:"os"` + Platform string `yaml:"platform"` } +// The image name every stage publishes under. The debian stage is a tag suffix +// on this name rather than a package of its own — see the architecture docs. +const imageName = "ghcr.io/specsnl/labelsync" + +// The Dockerfile ARG the version is injected through, which the shared workflow +// has to be told the name of. +const versionBuildArg = "LABELSYNC_VERSION" + +// buildJobs maps each build job to the manifest job that merges its digests, +// and publishedTargets maps each build job to the Dockerfile stage it ships. +var ( + buildJobs = map[string]string{ + "image": "image-manifest", + "image-debian": "image-debian-manifest", + } + publishedTargets = map[string]string{ + "image": "binary", + "image-debian": "debian", + } +) + func loadReleaseWorkflow(t *testing.T) releaseWorkflow { t.Helper() @@ -235,106 +265,154 @@ func loadReleaseWorkflow(t *testing.T) releaseWorkflow { t.Fatalf("parse the release workflow: %v", err) } - if _, ok := workflow.Jobs["images"]; !ok { - t.Fatalf("the release workflow has no `images` job; jobs are %v", slices.Sorted(maps.Keys(workflow.Jobs))) + for build, manifest := range buildJobs { + for _, name := range []string{build, manifest} { + if _, ok := workflow.Jobs[name]; !ok { + t.Fatalf("the release workflow has no %q job; jobs are %v", + name, slices.Sorted(maps.Keys(workflow.Jobs))) + } + } } return workflow } -// imagesStepWith returns the `with:` value of the first step of the images job -// whose `uses:` names action, as a string — the block mixes strings and -// booleans, so everything is read back through fmt.Sprint rather than typed per +// with reads one `with:` input as a string. The block mixes strings and +// booleans, so everything comes back through fmt.Sprint rather than typed per // key. -func imagesStepWith(t *testing.T, action, key string) string { +func (j releaseJob) with(t *testing.T, key string) string { t.Helper() - for _, step := range loadReleaseWorkflow(t).Jobs["images"].Steps { - if !strings.Contains(step.Uses, action) { - continue + value, ok := j.With[key] + if !ok { + t.Fatalf("the job has no %q input; it has %v", key, slices.Sorted(maps.Keys(j.With))) + } + + return fmt.Sprint(value) +} + +// Two images, from two stages of one Dockerfile, both published under one name: +// the scratch stage unsuffixed and debian as a `-debian` tag suffix, which is +// how the org publishes a CLI's base-image variants. A build job without its +// manifest job pushes digests nothing ever merges into a tag — a release that +// goes green and publishes nothing pullable. +func TestRelease_ImageJobsPublishBothStages(t *testing.T) { + workflow := loadReleaseWorkflow(t) + stages := dockerfileStages(t) + + for build, manifest := range buildJobs { + target := publishedTargets[build] + + if _, ok := stages[target]; !ok { + t.Errorf("the Dockerfile has no %q stage for the %s job; stages are %v", + target, build, slices.Sorted(maps.Keys(stages))) } - value, ok := step.With[key] - if !ok { - t.Fatalf("the %s step has no %q; it has %v", action, key, slices.Sorted(maps.Keys(step.With))) + for _, name := range []string{build, manifest} { + job := workflow.Jobs[name] + + if got := job.with(t, "image-name"); got != imageName { + t.Errorf("%s publishes %q, want %q", name, got, imageName) + } + + if got := job.with(t, "target"); got != target { + t.Errorf("%s builds target %q, want %q", name, got, target) + } } - return fmt.Sprint(value) + if got := workflow.Jobs[manifest].Needs; got != build { + t.Errorf("%s needs %q, want %q — a manifest merges digests its build job has to have pushed", + manifest, got, build) + } } - t.Fatalf("the images job has no step using %s", action) + // The variant is what turns debian into a tag suffix. Without it the two + // manifests write the same tags from different digests, and whichever + // finishes last owns `:1.2.3`. + if got := workflow.Jobs["image-debian-manifest"].with(t, "variant"); got != "debian" { + t.Errorf("image-debian-manifest variant = %q, want %q", got, "debian") + } - return "" + if _, ok := workflow.Jobs["image-manifest"].With["variant"]; ok { + t.Error("image-manifest sets a variant, which would suffix the tags the scratch image publishes as the default") + } } -// Two packages, from two stages of one Dockerfile. The names are the addresses -// consumers pin to and the stages are how `target:` selects a runtime, so a -// rename on either side of that pairing silently publishes the wrong image — -// or, if the stage no longer exists, publishes nothing until the release fails. -func TestRelease_ImagesJobPublishesBothPackages(t *testing.T) { - include := loadReleaseWorkflow(t).Jobs["images"].Strategy.Matrix.Include +// One runner per architecture, each building for its own platform. Pointing both +// legs at the same runner still produces a manifest list — buildx would emulate +// the foreign one — and the release succeeds either way, so nothing but this +// says which happened. +func TestRelease_ImageJobsBuildOnANativeRunnerPerPlatform(t *testing.T) { + workflow := loadReleaseWorkflow(t) want := map[string]string{ - "ghcr.io/specsnl/labelsync": "binary", - "ghcr.io/specsnl/labelsync/debian": "debian", + "linux/amd64": "ubuntu-24.04", + "linux/arm64": "ubuntu-24.04-arm", } - if len(include) != len(want) { - t.Fatalf("the images matrix has %d entries, want %d", len(include), len(want)) - } + for build := range buildJobs { + runners := workflow.Jobs[build].Strategy.Matrix.Runner - stages := dockerfileStages(t) + if len(runners) != len(want) { + t.Errorf("%s has %d matrix legs, want %d", build, len(runners), len(want)) + } - for _, entry := range include { - target, ok := want[entry.Package] - if !ok { - t.Errorf("unexpected package %q in the images matrix", entry.Package) + seen := map[string]bool{} - continue - } + for _, runner := range runners { + native, ok := want[runner.Platform] + if !ok { + t.Errorf("%s builds unexpected platform %q", build, runner.Platform) - if entry.Target != target { - t.Errorf("%s builds target %q, want %q", entry.Package, entry.Target, target) - } + continue + } - if _, ok := stages[entry.Target]; !ok { - t.Errorf("the Dockerfile has no %q stage for %s; stages are %v", - entry.Target, entry.Package, slices.Sorted(maps.Keys(stages))) - } + if runner.OS != native { + t.Errorf("%s builds %s on %q, want %q", build, runner.Platform, runner.OS, native) + } - delete(want, entry.Package) - } + seen[runner.Platform] = true + } - for pkg := range want { - t.Errorf("the images matrix does not publish %s", pkg) + for platform := range want { + if !seen[platform] { + t.Errorf("%s does not build %s; an arm64 host pulling the release would fail at `docker run`", + build, platform) + } + } } } -// Both platforms in one push, so every tag of one release resolves to one -// manifest digest. Dropping a platform is invisible in review: the release still -// succeeds, and an arm64 runner pulling it fails at `docker run` instead. -func TestRelease_ImagesCoverBothPlatforms(t *testing.T) { - platforms := imagesStepWith(t, "docker/build-push-action", "platforms") +// The shared workflow injects the version through the build arg it is named +// here, and a wrong name fails silently: buildx warns about an unused arg, the +// build succeeds, and the published image reports `dev`. +func TestRelease_ImageJobsNameTheVersionBuildArg(t *testing.T) { + workflow := loadReleaseWorkflow(t) - for _, platform := range []string{"linux/amd64", "linux/arm64"} { - if !strings.Contains(platforms, platform) { - t.Errorf("platforms = %q, want it to include %q", platforms, platform) + for build := range buildJobs { + if got := workflow.Jobs[build].with(t, "version-build-arg"); got != versionBuildArg { + t.Errorf("%s injects the version through %q, want %q", build, got, versionBuildArg) } } - if push := imagesStepWith(t, "docker/build-push-action", "push"); push != "true" { - t.Errorf("push = %q, want %q — the job would build both images and publish neither", push, "true") + if !strings.Contains(readDockerfile(t), "ARG "+versionBuildArg+"=") { + t.Errorf("the Dockerfile declares no ARG %s for the workflow to pass the version to", versionBuildArg) } } // Pushing to GHCR needs `packages: write`, which the workflow-level block does -// not grant: it is read-only so each job asks for its own write scope. Without -// this the whole release goes green and the images fail at the push. -func TestRelease_ImagesJobCanWritePackages(t *testing.T) { +// not grant: it is read-only so each job asks for its own write scope. A +// reusable workflow inherits nothing it is not given, so the grant has to sit on +// the calling job — without it the release goes green and the push fails. +func TestRelease_ImageJobsCanWritePackages(t *testing.T) { workflow := loadReleaseWorkflow(t) - if got := workflow.Jobs["images"].Permissions["packages"]; got != "write" { - t.Errorf("the images job has packages: %q, want %q", got, "write") + for build, manifest := range buildJobs { + for _, name := range []string{build, manifest} { + if got := workflow.Jobs[name].Permissions["packages"]; got != "write" { + t.Errorf("the %s job has packages: %q, want %q", name, got, "write") + } + } } if got := workflow.Jobs["release"].Permissions["contents"]; got != "write" { @@ -342,49 +420,44 @@ func TestRelease_ImagesJobCanWritePackages(t *testing.T) { } } -// The tag policy is the whole contract a consumer pins against, and every part -// of it is one line of config that a plausible-looking edit can drop. A missing -// {{major}}.{{minor}} leaves early consumers pinned to a patch forever; a -// missing v0. guard promises stability across the releases most likely to break; -// a `latest=true` flavour would hand `docker run …:latest` a release candidate. -func TestRelease_ImageTagsFollowThePolicy(t *testing.T) { - tags := imagesStepWith(t, "docker/metadata-action", "tags") - - for _, want := range []string{ - "type=semver,pattern={{version}}", - "type=semver,pattern={{major}}.{{minor}}", - "type=semver,pattern={{major}},enable=", - } { - if !strings.Contains(tags, want) { - t.Errorf("the tag list does not contain %q:\n%s", want, tags) +// The tag policy, the login, the digest merge and the OCI labels all live in +// specsnl/github-actions now, so what this repository still owns is the pin. A +// half-finished bump — one job moved, three left behind — builds digests with +// one version of the pipeline and merges them with another. +func TestRelease_SharedWorkflowsArePinnedToOneRef(t *testing.T) { + refs := map[string][]string{} + + for _, path := range []string{".github/workflows/release.yml", ".github/workflows/ci.yml"} { + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) } - } - // No `v` prefix anywhere: `labelsync version` prints the tag without one. - if strings.Contains(tags, "prefix=v") || strings.Contains(tags, "v{{version}}") { - t.Errorf("the tag list prefixes tags with a v, which the version command never prints:\n%s", tags) - } + for line := range strings.Lines(string(content)) { + _, after, ok := strings.Cut(line, "specsnl/github-actions") + if !ok { + continue + } - if !strings.Contains(tags, "!startsWith(github.ref, 'refs/tags/v0.')") { - t.Errorf("the bare {{major}} is not guarded against 0.x, so a 0.x release would publish a :0 tag:\n%s", tags) - } + _, ref, ok := strings.Cut(after, "@") + if !ok { + t.Errorf("%s uses specsnl/github-actions without a ref:\n%s", path, strings.TrimSpace(line)) + + continue + } - if flavor := imagesStepWith(t, "docker/metadata-action", "flavor"); !strings.Contains(flavor, "latest=auto") { - t.Errorf("flavor = %q, want it to set latest=auto — a prerelease must not move :latest", flavor) + ref = strings.TrimSpace(ref) + refs[ref] = append(refs[ref], path+": "+strings.TrimSpace(line)) + } } -} -// GHCR links a package to this repository through org.opencontainers.image.source, -// and an unlinked package inherits neither the repository's visibility nor its -// permissions — so it stays private, and off the Packages sidebar, however many -// releases push to it. -func TestRelease_ImagesCarryTheOCILabels(t *testing.T) { - for _, key := range []string{"labels", "annotations"} { - value := imagesStepWith(t, "docker/build-push-action", key) + if len(refs) == 0 { + t.Fatal("neither workflow calls specsnl/github-actions; the image pipeline is not shared at all") + } - if !strings.Contains(value, "steps.meta.outputs."+key) { - t.Errorf("%s = %q, want it to come from the metadata action, which emits image.source", key, value) - } + if len(refs) > 1 { + t.Errorf("the shared workflows are pinned to %d different refs: %v", + len(refs), slices.Sorted(maps.Keys(refs))) } } @@ -440,10 +513,11 @@ func TestRelease_DockerfileBuildFlagsMatchGoreleaser(t *testing.T) { } } -// An arm64 image needs no emulation at all, but only while the builder is pinned -// to the platform doing the building and takes its GOOS/GOARCH from the platform -// being built for. Unpin either and the arm64 leg runs apt-get and the whole -// compile under QEMU, which is slow enough to look like a hung release. +// The release builds each platform on its own runner, so nothing there is ever +// emulated — but a `--platform` build anywhere else is, unless the builder stays +// pinned to the platform doing the building and takes its GOOS/GOARCH from the +// platform being built for. Unpin either and `docker buildx build --platform +// linux/arm64` on a laptop runs apt-get and the whole compile under QEMU. func TestRelease_ImagesCrossCompileRatherThanEmulate(t *testing.T) { dockerfile := readDockerfile(t) diff --git a/taskfiles/Taskfile.image.yml b/taskfiles/Taskfile.image.yml new file mode 100644 index 0000000..2739815 --- /dev/null +++ b/taskfiles/Taskfile.image.yml @@ -0,0 +1,74 @@ +# https://taskfile.dev +version: "3" + +silent: true + +vars: + IMAGE: '{{ .IMAGE | default "ghcr.io/specsnl/labelsync" }}' + LABELSYNC_VERSION: + sh: described=$(git describe --tags --always --dirty="-dev" 2>/dev/null || echo "dev"); echo "${described#v}" + +tasks: + + build: + desc: Build both published runtime images for the host platform + summary: | + Builds the two stages the release workflow publishes and loads them into + the local docker: + + ghcr.io/specsnl/labelsync:dev the scratch image, `binary` stage + ghcr.io/specsnl/labelsync:dev-debian the debian image, `debian` stage + + The tags mirror the published ones, where the debian variant is a suffix + on the same image name rather than a package of its own. + + Host platform only: --load writes into the docker image store, which holds + one platform per tag. + cmds: + - task: build:one + vars: { TARGET: binary, TAG: dev } + - task: build:one + vars: { TARGET: debian, TAG: dev-debian } + + build:one: + internal: true + cmds: + - >- + docker buildx build . + --target {{ .TARGET }} + --tag {{ .IMAGE }}:{{ .TAG }} + --build-arg LABELSYNC_VERSION={{ .LABELSYNC_VERSION }} + --load + requires: + vars: [TARGET, TAG] + + smoke: + desc: Build both runtime images and run the acceptance checks CI runs + summary: | + Runs test/image.bats against each image, which is the same file the + `Image (...)` jobs in ci.yml run — the version the binary reports, the + user it runs as, the CA bundle, and a bind-mounted config. + + The checks drive docker through a socket proxy, so they need the daemon on + the host rather than the one `task build` uses. + deps: [build] + cmds: + - mkdir -p /tmp/labelsync-image-smoke + - defer: docker compose --profile image rm --stop --force docker-socket-proxy + - task: smoke:one + vars: { TARGET: binary, TAG: dev } + - task: smoke:one + vars: { TARGET: debian, TAG: dev-debian } + + smoke:one: + internal: true + cmds: + - task: :dc:run:bats + vars: + RUN_FLAGS: >- + --env IMAGE={{ .IMAGE }}:{{ .TAG }} + --env EXPECTED_VERSION={{ .LABELSYNC_VERSION }} + --env TARGET={{ .TARGET }} + SUB_CMD: test/image.bats + requires: + vars: [TARGET, TAG] diff --git a/taskfiles/Taskfile.lint.yml b/taskfiles/Taskfile.lint.yml index 9cb7c98..2bfee60 100644 --- a/taskfiles/Taskfile.lint.yml +++ b/taskfiles/Taskfile.lint.yml @@ -15,3 +15,10 @@ tasks: - task: dc:run:golangci-lint vars: SUB_CMD: "golangci-lint run --fix" + + lint:docker: + desc: Lint the Dockerfile with hadolint + cmds: + - task: dc:run:hadolint + vars: + SUB_CMD: "hadolint Dockerfile" diff --git a/test/image.bats b/test/image.bats new file mode 100644 index 0000000..820ef8e --- /dev/null +++ b/test/image.bats @@ -0,0 +1,132 @@ +#!/usr/bin/env bats +# +# Acceptance checks for the published runtime images, run by both ci.yml and +# `task image:smoke`. +# +# IMAGE and EXPECTED_VERSION name the image under test and the version it must +# report. TARGET is the Dockerfile stage it was built from: the two stages ship +# the same binary on different bases, so a handful of checks belong to one of +# them and are skipped on the other. + +setup_file() { + bats_require_minimum_version 1.5.0 + + : "${IMAGE:?set IMAGE to the image reference under test}" + : "${EXPECTED_VERSION:?set EXPECTED_VERSION to the version the image must report}" + : "${TARGET:?set TARGET to the Dockerfile stage the image was built from}" +} + +setup() { + bats_load_library bats-support + bats_load_library bats-assert + + # Under TMPDIR, which points at a directory mounted at the same path on the + # host — the daemon resolves the bind mounts below against the host. + WORKDIR="$(mktemp -d)" + + # The images run as 65534, which is nobody and is not whoever created + # WORKDIR. A 0700 directory would fail the bind-mount checks on traversal + # permissions rather than on anything they are about. + chmod 0755 "$WORKDIR" +} + +teardown() { + rm -rf "$WORKDIR" +} + +# Runs the image against a config bind-mounted read-only, the way the CI recipe +# tells people to mount theirs. +run_with_config() { + local body="$1" + shift + + printf '%s\n' "$body" > "$WORKDIR/labels.yml" + chmod 0644 "$WORKDIR/labels.yml" + + docker run --rm \ + --volume "$WORKDIR/labels.yml:/labels.yml:ro" \ + "$IMAGE" "$@" +} + +# Copies a path out of the image without running it — the scratch image has no +# shell to read a file with. The container is removed whether or not the copy +# worked, and the copy's status is what the caller sees. +extract() { + local path="$1" dest="$2" cid status=0 + + cid="$(docker create "$IMAGE")" + docker cp "$cid:$path" "$dest" || status=$? + docker rm --force "$cid" > /dev/null + + return "$status" +} + +@test "reports the version injected at build time" { + run docker run --rm "$IMAGE" version --dont-prettify + + assert_success + # "dev" here means LABELSYNC_VERSION never reached the ldflag — which is + # what a renamed build arg looks like: buildx warns, the build succeeds. + assert_output "$EXPECTED_VERSION" +} + +@test "runs as nobody rather than root" { + run docker inspect --format '{{.Config.User}}' "$IMAGE" + + assert_success + assert_output "65534:65534" +} + +@test "ships the CA bundle where Go looks for it" { + run extract /etc/ssl/certs/ca-certificates.crt "$WORKDIR/ca-certificates.crt" + + # Without the trailing slash on the COPY the bundle lands as a file named + # /etc/ssl/certs, this copy fails, and every API call would have failed + # with "failed to load system roots" long after the version check passed. + assert_success + assert [ -s "$WORKDIR/ca-certificates.crt" ] +} + +@test "parses a bind-mounted config as the unprivileged user" { + run run_with_config 'version: 1 + +labels: + - name: "type: bug" + color: "d73a4a"' sync --config /labels.yml --output=json + + # Nothing in the container can produce a token, so the run stops at + # resolving one — which is the assertion: reaching the token means the + # mounted file was readable and the YAML parsed. + assert_failure 1 + assert_output --partial '"error_kind":"no_token"' +} + +@test "reports a config error from the mounted file" { + run run_with_config 'version: 1 + +labels: + - name: "type: bug" + color: "nope"' sync --config /labels.yml --output=json + + assert_failure 1 + assert_output --partial '"error_kind":"invalid_color"' +} + +@test "the debian image has a shell with labelsync on PATH" { + [ "$TARGET" = debian ] || skip "the $TARGET stage is scratch and ships no shell" + + run docker run --rm --entrypoint bash "$IMAGE" -c 'command -v labelsync' + + assert_success + assert_output "/usr/local/bin/labelsync" +} + +@test "the scratch image gives its uid a name" { + [ "$TARGET" = binary ] || skip "the $TARGET stage inherits a passwd file from its base" + + run extract /etc/passwd "$WORKDIR/passwd" + assert_success + + run grep -q '^nobody:' "$WORKDIR/passwd" + assert_success +}