---
title: The built skift is published to GHCR on every push to main
canonical: https://skift.stead.ing/docs/plans/2026-09-22-skift-2/
site: skift
---

# 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

```sh
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).
