From 0388583eb6fbb9209bb7faac12c2bbab8aa0c360 Mon Sep 17 00:00:00 2001 From: Ilyes512 Date: Wed, 16 Sep 2026 22:52:35 +0200 Subject: [PATCH 1/4] ci(release): publish the images through the org's shared pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the hand-rolled `images` job with specsnl/github-actions' build-go-cli.yml and merge-go-cli.yml at 2.4.1: one build job per platform on a runner of that architecture, then a manifest merge of the digests. Login, buildx, the tag policy, the OCI labels, `provenance: false` and a layer cache all come with it. What stays here is what only this repository can know: that the stages to publish are `binary` and `debian`, that they publish under ghcr.io/specsnl/labelsync, and that the version build arg is called LABELSYNC_VERSION. The version itself is not passed — the shared workflow defaults it to the tag without its leading v, which is what GoReleaser injects, so the image and the tarball cut from one tag agree about what they are. The debian image moves from the nested ghcr.io/specsnl/labelsync/debian to a `-debian` tag suffix on the one image name, which is how the shared pipeline publishes a CLI's base-image variants and how node, python and postgres publish theirs. The nested package is frozen at 0.1.0-rc.4. The workflow tests follow the new shape: the four jobs and their pairing, the native runner per platform, the version build arg matching the Dockerfile's ARG, `packages: write` on each calling job, and one shared-workflow ref across the whole tree. The tag policy is no longer asserted here — it lives in the shared workflow now. --- .github/workflows/release.yml | 96 ++++++----- release_test.go | 302 +++++++++++++++++++++------------- 2 files changed, 241 insertions(+), 157 deletions(-) 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/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) From 4c004dacc68e8080c3915d2b8ca020997d64c20a Mon Sep 17 00:00:00 2001 From: Ilyes512 Date: Wed, 16 Sep 2026 22:52:51 +0200 Subject: [PATCH 2/4] ci: build and smoke-test both runtime images on pull requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A broken Dockerfile should fail on the pull request, not while a tag is being cut. A matrix over both stages and both architectures builds through specsnl/github-actions' build-image action and runs test/image.bats against the result. The action is called directly rather than through 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 this job 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 foreign build can be compiled but not executed. The script asserts the version the binary reports (built with a string no fallback can produce, so a renamed build arg is loud rather than silent), the uid it runs as, the CA bundle, and a bind-mounted config — both a valid one, which gets as far as failing on the token, and an invalid one, which comes back with its error_kind. The two stage-specific properties, the shell and /etc/passwd, are skipped on the stage that does not have them. `task image:smoke` runs the same file locally, through a bats stage in the Dockerfile and a socket proxy, replacing `task images` and its version-only check. `task image:build` is the build half on its own. --- .github/workflows/ci.yml | 45 ++++++++++ Dockerfile | 38 +++++++++ Taskfile.dist.yml | 37 +------- ci_test.go | 161 +++++++++++++++++++++++++++++++++++ compose.yml | 35 ++++++++ taskfiles/Taskfile.image.yml | 74 ++++++++++++++++ test/image.bats | 132 ++++++++++++++++++++++++++++ 7 files changed, 486 insertions(+), 36 deletions(-) create mode 100644 ci_test.go create mode 100644 taskfiles/Taskfile.image.yml create mode 100644 test/image.bats diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8fceaee..6fb6d12 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,3 +74,48 @@ jobs: - name: Test run: go test -race -count=1 -tags=integration ./... + 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/Dockerfile b/Dockerfile index 71db8cf..ebc662f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,6 +40,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/Taskfile.dist.yml b/Taskfile.dist.yml index 468b5e7..eb6ab9c 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: | 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..a6cda82 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} 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/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 +} From e769d723ad1c898e01e6d689d73ca51d066c349f Mon Sep 17 00:00:00 2001 From: Ilyes512 Date: Wed, 16 Sep 2026 22:52:58 +0200 Subject: [PATCH 3/4] docs: document the shared image pipeline and the -debian variant Distribution gains the four jobs of configuration that are all this repository still owns, the new tag table including the v-prefixed alias the shared pipeline emits, why one runner per platform replaced one multi-platform build, and what the pull-request guard asserts. The manual step is one package now rather than two. The usage pages and the README carry the rename: the debian image is ghcr.io/specsnl/labelsync:0.1-debian, not a package of its own. --- AGENTS.md | 46 ++-- README.md | 4 +- .../content/docs/architecture/distribution.md | 220 ++++++++++++------ docs/content/docs/usage/ci.md | 20 +- 4 files changed, 189 insertions(+), 101 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 06e4903..2254858 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,25 +20,27 @@ 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`, `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 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 @@ -120,6 +122,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 +155,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/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/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 From 72a8b9f01524edbc1d010d5a4210df7a2d7e1fa6 Mon Sep 17 00:00:00 2001 From: Ilyes512 Date: Sat, 19 Sep 2026 15:42:18 +0200 Subject: [PATCH 4/4] build(lint): lint the Dockerfile with hadolint `task lint:docker` runs hadolint through a compose service under the lint profile, the same shape as golangci-lint, and joins `task checkall`. CI runs the task itself rather than hadolint/hadolint-action, which ships its own hadolint build and would put the version in a second place to keep in sync. One finding to clear, which specs-cli had already fixed: the base stage kept its apt lists after installing. Version pinning is off in .hadolint.yml: 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. --- .github/workflows/ci.yml | 16 ++++++++++++ .hadolint.yml | 6 +++++ AGENTS.md | 51 ++++++++++++++++++++----------------- Dockerfile | 3 ++- Taskfile.dist.yml | 5 ++-- compose.yml | 9 +++++++ taskfiles/Taskfile.lint.yml | 7 +++++ 7 files changed, 70 insertions(+), 27 deletions(-) create mode 100644 .hadolint.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6fb6d12..5d9e80c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,6 +74,22 @@ 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 }} 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 2254858..b4020b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,27 +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 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 | +| 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 @@ -50,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. diff --git a/Dockerfile b/Dockerfile index ebc662f..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 diff --git a/Taskfile.dist.yml b/Taskfile.dist.yml index eb6ab9c..50eb8dc 100644 --- a/Taskfile.dist.yml +++ b/Taskfile.dist.yml @@ -133,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/compose.yml b/compose.yml index a6cda82..fbacbeb 100644 --- a/compose.yml +++ b/compose.yml @@ -168,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/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"