Skip to content

feat(docker): publish an official specs image to GHCR - #146

Merged
Ilyes512 merged 5 commits into
mainfrom
feat/GH-81-docker-image
Sep 16, 2026
Merged

Ilyes512 merged 5 commits into
mainfrom
feat/GH-81-docker-image

Conversation

@Ilyes512

Copy link
Copy Markdown
Member

Closes #81.

Summary

  • Turns the Dockerfile's anonymous debian stage into the publishable runtime image: named, last, non-root specs at 1000:1000, CA bundle, XDG_CONFIG_HOME=/config, ENTRYPOINT.
  • Publishes ghcr.io/specsnl/specs-cli multi-arch on every v* tag via the org's shared build-go-cli.yml / merge-go-cli.yml at 2.4.0.
  • Guards it on every pull request: builds on a native runner per architecture and runs the image, not just the build.
  • Documents the runtime contract — bind mounts, TTY, hooks, registry volume, SSH.

Notes for review

Why debian and not scratch. internal/hooks runs hooks through bash -c and refuses without it, so a shell-less image would silently break every template that defines one. Cloning needs no git binary (go-git), so the runtime borrows only the binary and ca-certificates.crt — the slim images ship no CA bundle.

debian is the last stage on purpose, so a bare docker build . produces the image that ships rather than a dev tool. The workflows still pass --target debian explicitly.

Native runners, not QEMU. The runtime stage creates its user with a RUN, which executes on the target platform — under emulation that build can't run its own output, so the PR guard could only compile the foreign arch, never execute it.

task image:smoke runs the same script CI does: --version is the injected version and not dev, the default user is uid=1000(specs), bash is present, a template scaffolds into a bind mount with hooks run and files owned by the caller, and a non-TTY run refuses instead of silently taking defaults.

Needs a human

  • The GHCR package is private until someone makes it public or links it to the repo, after the first tag. No workflow can do this.
  • The two Image (...) jobs are not in the branch ruleset's required checks, so they run but do not gate. Worth adding if that is wanted.

Verified locally

task lint, task test, task md:check, task image:smoke, task build, task docs:build, task dc:build — all green. The digest/manifest path only exercises on a real tag.

The Dockerfile only had stages for developing specs: builder-download for
`task test`, export for `task build`, vhs for the demo tapes. The debian stage
existed but was anonymous, shipped no CA bundle, ran as root and used CMD, so
nothing about it was publishable.

Make it the image that ships:

- Name it `debian` and move it last, so `docker build .` with no --target
  builds the runtime image rather than a dev tool. Safe because every consumer
  already pins its stage: compose.yml selects builder-download, export and vhs
  by name.
- Copy the CA bundle out of the builder. debian:*-slim ships none, and without
  one every HTTPS template source fails to clone.
- Run as a non-root specs:specs at 1000:1000, with /config and /work created
  and owned by it, and XDG_CONFIG_HOME pinned to /config so the template
  registry has one mount point whatever uid the container runs as.
- ENTRYPOINT instead of CMD, so `docker run … specs-cli use <template> .`
  works as written.
- Cross-compile instead of emulating: base is pinned to $BUILDPLATFORM and the
  build stage falls back to TARGETOS/TARGETARCH. GOOS/GOARCH lose their
  defaults so the fallback can tell "unset" from "asked for linux" — `task
  build` still passes both explicitly through buildx bake.
- Drop the scratch `binary` stage. Nothing referenced it and it was never
  published; the shell-less image it produced could not run hooks anyway.

Refs GH-81
`task image:build` builds the runtime stage for the host platform; `task
image:smoke` runs test/image.bats against it — version injection, the non-root
default user, bash on PATH, a real scaffold into a bind mount with hooks,
ownership of the written files, and the non-TTY refusal. CI runs the same
suite, so a local green and a pull-request green mean the same thing.

bats rather than a hand-rolled script for two reasons that matter here. Each
check is its own test, so a change that breaks two things reports both instead
of stopping at the first. And bats-assert reports a failure as a diff, which a
`[ "$a" = "$b" ] || fail "..."` chain cannot do without writing the message out
by hand every time.

Locally the suite runs in a `bats` compose service built from a new Dockerfile
stage — bats, its three support libraries, and a static docker client — driving
the host daemon through the docker-socket-proxy the vhs service already uses.
That has one consequence worth knowing before editing the tests: the daemon
resolves their bind mounts on the *host*, so a container-only path does not
exist by the time it is used. The service therefore mounts the repository at
its own host path and points TMPDIR at a directory mounted identically, the
same trick vhs uses for /tmp/specs-demo.

The stage is deliberately placed before `debian`, which has to stay last so a
bare `docker build .` keeps producing the image that ships.

Refs GH-81
A broken Dockerfile should fail on the pull request, not while a tag is being
cut. A matrix over ubuntu-24.04 and ubuntu-24.04-arm builds --target debian
through specsnl/github-actions' build-image action and runs the acceptance
script 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.

One runner per architecture rather than one runner emulating both: a native
build can execute the image it just produced, so both architectures are
smoke-tested rather than only compiled.

Refs GH-81
Publishes ghcr.io/specsnl/specs-cli for linux/amd64 and linux/arm64 on every v*
tag, through the org's shared build-go-cli.yml and merge-go-cli.yml at 2.4.0.
Those own the whole pipeline — native runner per architecture, push by digest,
manifest merge, and the tag set, including keeping :latest and the major tag
off prereleases.

What stays here is what only this repository can know: that the stage to
publish is `debian`, and that the version build arg is called SPECS_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
Homebrew binary cut from one tag agree about what they are.

The workflow-level `contents: write` grant moves down onto the release job
rather than being widened — the image jobs need packages: write and nothing
else.

Refs GH-81
installation.md gains a Docker section covering the bits a "run the binary in a
container" image does not get for free: the tag scheme, --user with the HOME it
has to be paired with, -it and what a missing terminal actually does, the hooks
warning and the flags that contain it, the /config volume for the registry, and
SSH sources — including the known_hosts mount, which is required rather than
optional because host key verification fails the clone without it.

release.md documents the image pipeline next to the GoReleaser one: what is
published from which stage, why only `debian` ships, how cross-compilation is
wired, the tag table, the one-time GHCR visibility step, and the pull-request
guard.

The README gets a Docker line in Install and, in Contributing, the distinction
between the dev stages and the published one.

Refs GH-81
@Ilyes512
Ilyes512 force-pushed the feat/GH-81-docker-image branch from 9c5ad0b to 2aa5fc8 Compare September 16, 2026 19:57
@Ilyes512
Ilyes512 merged commit 4239e31 into main Sep 16, 2026
7 checks passed
@Ilyes512
Ilyes512 deleted the feat/GH-81-docker-image branch September 16, 2026 20:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add official specs-cli docker images

1 participant