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
-
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}. NoENTRYPOINT: the acceptance criterion runspodman run --rm <image> skift version—skiftas the container’s command, not as an argument to a baked-in one — and anENTRYPOINT ["/usr/local/bin/skift"]would turn that intoskift skift version, whichskiftrejects as an unknown subcommand. Add aDescribeblock topackaging_test.go(or a newpackaging_image_test.gobeside it, following the samecheckout()/runIn()/git()helpers already in that file) that: takes a fresh checkout, runsmake dist/skift-linux-$(go env GOARCH) VERSION=9.9.9, builds the image withpodman build --build-arg TARGETARCH=$(go env GOARCH) --build-arg REVISION=deadbeef -f packaging/Containerfile -t skift-image-test ., then assertspodman run --rm skift-image-test skift versionprints9.9.9— the same assertion shape as the existing “the version”Describeblock just above the rpm one. Label ittestsupport.DBLabel(db): it needs podman, like the testcontainers specs, and must not run insideunit_test.go’s nestedmake test-unitinvocation, which deliberately makes podman unreachable to prove that target needs no container. Proof:make test(ormake test-db) passes, including the new spec;make test-unitstill passes with podman stubbed out, unaffected by the new file. -
.github/workflows/publish.yml.on: push: branches: [main], nopull_requesttrigger.permissions: contents: read, packages: write. Steps:actions/checkout@v4withfetch-depth: 0(required today, before anyv*tag exists, becauseMakefile’s fallback VERSION usesgit rev-list --count HEAD, which a shallow clone undercounts);actions/setup-go@v5withgo-version-file: go.modandcache: true(the Go module cache the work order’s Notes ask to keep); computeVERSION="$(make version)"and runmake dist/skift-linux-amd64 dist/skift-linux-arm64 VERSION="$VERSION";docker/setup-buildx-action@v3;docker/login-action@v3againstghcr.iowithusername: ${{ github.actor }},password: ${{ secrets.GITHUB_TOKEN }};docker/build-push-action@v6withcontext: .,file: packaging/Containerfile,platforms: linux/amd64,linux/arm64,push: true,build-args: REVISION=${{ github.sha }},tags: ghcr.io/steading-ai/skift:${{ github.sha }}andghcr.io/steading-ai/skift:main. Proof: the workflow is new YAML with no existing lint gate over it (.golangci.ymlandhk.pkl’s glob cover**/*.goand friends, not.github/**), so its syntax is proven by GitHub Actions itself on the first push tomainafter 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 sameTARGETARCH/REVISIONbuild-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). -
docs/ci.md. Document: the image isghcr.io/steading-ai/skift; it carries two tags per push tomain— the full commit SHA (immutable, one per commit) andmain(moving, always the latest push); it is built forlinux/amd64andlinux/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.pklgate covers.github/**, and there’s noact-style local GitHub Actions runner pinned inmise.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 tomainfails, the failure will be narrow and visible in the Actions log, not a silent wrong artifact. fetch-depth: 0on 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 wrongVERSIONon 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/steadingis 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 inmise.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 toREADME.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, notpodman, despite every other local workflow in this repo (compose.yaml,Makefile’sdb-*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 reimplementingVERSION/LDFLAGSas 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 fromtest-unit, included intest/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 fallbackgit rev-list --count HEADneeds full history to match a local clone) and will remain required once tags exist (git describe --tagsneeds them fetched).