The built skift is published to GHCR on every push to main

Approach

The Containerfile stays a pure distribution artifact — FROM scratch, COPY a binary that was already built, LABEL the revision — and does no Go compilation of its own. The two skift binaries come from the Makefile’s existing dist/skift-linux-% targets, the same ones make package already uses for the rpm, so VERSION/LDFLAGS is computed in exactly one place in the whole repository. The workflow’s job is then two ordinary steps: run make to produce dist/skift-linux-amd64 and dist/skift-linux-arm64, then run docker buildx build --platform linux/amd64,linux/arm64 --push against the Containerfile, which picks the right prebuilt binary per platform via buildx’s automatic TARGETARCH build arg. No QEMU is needed anywhere: COPY never executes the binary it copies, so cross-arch assembly is just moving bytes.

Rejected: a multi-stage Containerfile with a golang builder stage (FROM golang:1.25 AS build, cross-compile inside the image, FROM scratch final stage). It is the more common pattern for Go container images and needs no separate “build binaries first” step, but it forces the version string to be recomputed as a Docker build-arg, duplicating the sed pipeline in Makefile:11-19 a second time. That duplication is exactly the kind of drift the version-parity acceptance criterion is designed to catch, and there’s no reason to accept the risk when the rpm packaging path already proves the “build once via make, distribute via a packaging tool” pattern works.

Also rejected: building the image with podman build/podman push directly in the workflow, to match this repo’s podman-only local tooling. GitHub-hosted runners ship Docker and buildx natively; installing podman just to stay consistent with local dev habits would add a step that does nothing the runner’s own tooling doesn’t already do, for an image that is equally poddable on pull regardless of which OCI-compliant tool built it. The acceptance criterion’s podman run ... is a consumer-side check, not a build-tool mandate.

Steps

  1. packaging/Containerfile, and a spec that builds and runs it. Add the Containerfile: FROM scratch, ARG TARGETARCH, COPY dist/skift-linux-${TARGETARCH} /usr/local/bin/skift, ARG REVISION, LABEL org.opencontainers.image.revision=${REVISION}. No ENTRYPOINT: the acceptance criterion runs podman run --rm <image> skift version — skift as the container’s command, not as an argument to a baked-in one — and an ENTRYPOINT ["/usr/local/bin/skift"] would turn that into skift skift version, which skift rejects as an unknown subcommand. Add a Describe block to packaging_test.go (or a new packaging_image_test.go beside it, following the same checkout()/runIn()/git() helpers already in that file) that: takes a fresh checkout, runs make dist/skift-linux-$(go env GOARCH) VERSION=9.9.9, builds the image with podman build --build-arg TARGETARCH=$(go env GOARCH) --build-arg REVISION=deadbeef -f packaging/Containerfile -t skift-image-test ., then asserts podman run --rm skift-image-test skift version prints 9.9.9 — the same assertion shape as the existing “the version” Describe block just above the rpm one. Label it testsupport.DBLabel (db): it needs podman, like the testcontainers specs, and must not run inside unit_test.go’s nested make test-unit invocation, which deliberately makes podman unreachable to prove that target needs no container. Proof: make test (or make test-db) passes, including the new spec; make test-unit still passes with podman stubbed out, unaffected by the new file.

  2. .github/workflows/publish.yml. on: push: branches: [main], no pull_request trigger. permissions: contents: read, packages: write. Steps: actions/checkout@v4 with fetch-depth: 0 (required today, before any v* tag exists, because Makefile’s fallback VERSION uses git rev-list --count HEAD, which a shallow clone undercounts); actions/setup-go@v5 with go-version-file: go.mod and cache: true (the Go module cache the work order’s Notes ask to keep); compute VERSION="$(make version)" and run make dist/skift-linux-amd64 dist/skift-linux-arm64 VERSION="$VERSION"; docker/setup-buildx-action@v3; docker/login-action@v3 against ghcr.io with username: ${{ github.actor }}, password: ${{ secrets.GITHUB_TOKEN }}; docker/build-push-action@v6 with context: ., file: packaging/Containerfile, platforms: linux/amd64,linux/arm64, push: true, build-args: REVISION=${{ github.sha }}, tags: ghcr.io/steading-ai/skift:${{ github.sha }} and ghcr.io/steading-ai/skift:main. Proof: the workflow is new YAML with no existing lint gate over it (.golangci.yml and hk.pkl’s glob cover **/*.go and friends, not .github/**), so its syntax is proven by GitHub Actions itself on the first push to main after merge — record this as a risk below rather than a false proof. What is provable now: the two steps it shares with the step-1 spec (make dist/skift-linux-%, then a Containerfile build using the same TARGETARCH/REVISION build-args) are already exercised by that spec, so the only genuinely untested part is the GitHub Actions orchestration around them (triggers, permissions, buildx multi-platform push).

  3. docs/ci.md. Document: the image is ghcr.io/steading-ai/skift; it carries two tags per push to main — the full commit SHA (immutable, one per commit) and main (moving, always the latest push); it is built for linux/amd64 and linux/amd64; and before any other repository’s workflow can pull it, a person must visit the package’s GitHub settings (ghcr.io/steading-ai/skift → Package settings → Manage Actions access → add the consuming repository with the Read role) — a one-time, per-consuming-repository step that has no API and is not part of this workflow. Proof: none automated; this is prose, checked by reading it against the deliverables list.

Files

Path What happens
packaging/Containerfile New. scratch base, copies a prebuilt dist/skift-linux-${TARGETARCH} binary to /usr/local/bin/skift, labels org.opencontainers.image.revision.
packaging_test.go Edited. New Describe block builds the Containerfile with podman and asserts skift version inside it matches make version, labelled db.
.github/workflows/publish.yml New. Push-to-main-only workflow: builds both arches via make, then a multi-platform docker buildx push to GHCR tagged <sha> and main, authenticated with GITHUB_TOKEN only.
docs/ci.md New. Records the image name, the tag scheme, and the one-time manual GHCR package-access grant a person makes for a consuming repository.

Proof

make lint && make test

make lint is unaffected by this node (no Go, proto, or SQL source changes). make test runs the full suite including the new db-labelled image spec, which needs podman — already a prerequisite per the README. Passing means: the Containerfile builds, skift version inside the resulting image equals make version’s output for the same checkout, and every existing spec (including unit_test.go’s “needs no container” check) still passes.

The workflow YAML itself is not exercised by make test; it is proven by GitHub Actions on the first push to main after this change merges. That push is outside this repository checkout’s reach and is called out under Risks.

Risks

  • The workflow can’t be dry-run locally. No .golangci.yml/hk.pkl gate covers .github/**, and there’s no act-style local GitHub Actions runner pinned in mise.toml. Mitigation: keep the workflow’s two build-producing steps (make dist/skift-linux-%, then the Containerfile build) identical in shape to what the step-1 spec already proves works, so the only remaining risk surface is GitHub-Actions-specific glue (trigger, permissions, buildx multi-platform push) rather than the build logic itself. If the first push to main fails, the failure will be narrow and visible in the Actions log, not a silent wrong artifact.
  • fetch-depth: 0 on every push adds clone time. The repo has no tags yet, so history is still small; this is worth revisiting if the tree grows large enough for a full clone to matter, but not a reason to default to a shallow one now, since a shallow clone actively produces a wrong VERSION on this repo’s fallback path.
  • The GHCR access grant is a person’s step, not code. This node is done when the image publishes; the grant to steading-ai/steading is out of scope for the change itself and is only documented here, per the work order’s own Notes. A reviewer checking the Gherkin scenario end-to-end needs that grant made first — it is not something this plan can produce.
  • docker/* actions pull from Docker Hub / GitHub Marketplace at pinned major versions (@v3/@v4/@v6), not exact SHAs. Consistent with how this repo pins its own tools loosely-but-deliberately in mise.toml (exact versions there, but that’s a different ecosystem); no stricter pinning convention exists in this repo to follow, so major- version pins are the default rather than an invented stricter policy.

Decisions

  • Doc location: a new docs/ci.md, not an addition to README.md’s “Deploying” section — that section’s audience is a person standing up a systemd deployment from the rpm; this is CI-to-CI package consumption, a different audience and a different lifecycle.
  • Build tool in CI: docker buildx, not podman, despite every other local workflow in this repo (compose.yaml, Makefile’s db-* targets) being podman-only. The runner ships Docker natively; the produced image is standard OCI regardless of builder.
  • Version parity mechanism: reuse the existing dist/skift-linux-% Makefile targets rather than reimplementing VERSION/LDFLAGS as Docker build-args, so there is exactly one place the version string is computed.
  • Spec label for the new Containerfile test: db, even though it needs podman rather than PostgreSQL specifically. It’s the existing mechanism for “excluded from test-unit, included in test/test-db,” and inventing a new label for “needs podman but not postgres” is more machinery than one spec justifies.
  • History depth in checkout: fetch-depth: 0, required now (no tags exist, so the Makefile’s fallback git rev-list --count HEAD needs full history to match a local clone) and will remain required once tags exist (git describe --tags needs them fetched).

Edit this page