feat(docker): publish an official specs image to GHCR - #146
Merged
Merged
Conversation
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
Ilyes512
force-pushed
the
feat/GH-81-docker-image
branch
2 times, most recently
from
September 15, 2026 15:21
4875880 to
0aa1c43
Compare
Open
12 tasks
Ilyes512
force-pushed
the
feat/GH-81-docker-image
branch
from
September 16, 2026 19:11
0aa1c43 to
9c5ad0b
Compare
`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
force-pushed
the
feat/GH-81-docker-image
branch
from
September 16, 2026 19:57
9c5ad0b to
2aa5fc8
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #81.
Summary
specsat 1000:1000, CA bundle,XDG_CONFIG_HOME=/config,ENTRYPOINT.ghcr.io/specsnl/specs-climulti-arch on everyv*tag via the org's sharedbuild-go-cli.yml/merge-go-cli.ymlat 2.4.0.Notes for review
Why debian and not scratch.
internal/hooksruns hooks throughbash -cand refuses without it, so a shell-less image would silently break every template that defines one. Cloning needs nogitbinary (go-git), so the runtime borrows only the binary andca-certificates.crt— the slim images ship no CA bundle.debianis the last stage on purpose, so a baredocker build .produces the image that ships rather than a dev tool. The workflows still pass--target debianexplicitly.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:smokeruns the same script CI does:--versionis the injected version and notdev, the default user isuid=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
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.