Skip to content

build(image): ship semibase as a container image - #6

Merged
mrcsin merged 2 commits into
masterfrom
container-image
Aug 24, 2026
Merged

mrcsin merged 2 commits into
masterfrom
container-image

Conversation

@mrcsin

@mrcsin mrcsin commented Aug 24, 2026

Copy link
Copy Markdown
Member

Consumers resolve the semibase binary from SEMIBASE_EXE or PATH, so on a developer machine whichever build happens to be installed is the one that provisions. SemiPlot's fixture carries a pinned-version constant that nothing enforces.

An image fixes that at the source. SemiPlot's Dockerfile does COPY --from=ghcr.io/semiteq/semibase:latest /semibase onto postgres:17-alpine, where PR #5 lets it run as an init script over the unix socket.

The image

FROM scratch, one file, an ENTRYPOINT. Not alpine, not distroless: the only consumer copies the binary out and never uses the image as a base, and the module is pure pgx built CGO_ENABLED=0. It carries no init script — that is the consumer's — and no consumer knowledge.

3.39 MB compressed, one layer, one entry. The binary in it is cmp-identical to the release asset rather than a second build, so "the image contains the shipped binary" is provable.

latest only moves forward

SemiPlot tracks :latest deliberately: delivered installations update neither service, so the only pair ever newly deployed is newest-SemiBase with current-reader. That contract needs enforcing, and two paths would have broken it — re-running an old tag's workflow from the Actions UI, and two tags pushed together racing through a github.ref-keyed concurrency group.

One decision now gates both the GitHub release and the image push: no prerelease suffix and the tag is the newest release tag. Dry-run over a realistic tag set:

v0.2.0   with v0.1.0 present   -> latest moves
v0.1.0   with v0.2.0 present   -> refused, names the newer tag
v0.2.0-rc1                     -> prerelease, latest not moved
v0.10.0  with v0.9.0 present   -> latest moves   (version order, not string order)

The same gate drives prerelease and make_latest on the GitHub release, so the release page and the image tag cannot disagree — docs/deployment.md sends commissioning engineers to /releases/latest, and an rc must not land there either.

Measured, not assumed

  • FROM --platform= does not set the manifest architecture. Pinned to linux/arm64 on an amd64 host, the output was still linux/amd64 — scratch carries no config to inherit. Only docker build --platform works, so that is what carries the claim, with a manifest read-back that fails the job.
  • The push is after the release exists, so latest never names a version whose assets are missing. Partial failure is safe in the recoverable direction: release created, push failed leaves :latest on the previous version, and a plain workflow re-run converges because the build is -trimpath reproducible.
  • docker logout runs on an EXIT trap, so it covers the skip and failure paths too.

One operational item no workflow can do

The first push creates the GHCR package private, and SemiPlot's CI pulls anonymously. Someone flips it to public once in the package settings after the first tagged release. Documented rather than left to be found by a failing consumer build.

Also

/samples/ is added to .gitignore. That directory holds PostgreSQL dumps from a customer installation and was untracked but not ignored, in a public repository — the new image/ build context stopped the Docker daemon from seeing it, and nothing stopped git add -A.

🤖 Generated with Claude Code

mrcsin and others added 2 commits August 24, 2026 11:34
Consumers resolve the binary from PATH today, so whichever build happens to be
installed is the one that provisions their bench. A published image gives them
a version they can name: SemiPlot copies /semibase out of it onto its own
postgres image and runs it as an init script over the unix socket.

The image is FROM scratch and holds one file. Nothing runs it as a base, so a
shell, a libc and CA certificates would be weight nobody executes; the module
is pure pgx and builds CGO_ENABLED=0. The binary is copied from the linux
release artifact rather than built again, so the bytes in the image and the
bytes on the release page are the same.

The release job pushes :latest and an immutable :vX.Y.Z after creating the
GitHub release, never before - consumers still download the release assets.
A prerelease tag publishes its version tag and leaves :latest where it is.
It logs in with docker login rather than docker/login-action: no third-party
action needs the write-capable token for one command.

CI builds the image and runs it, so a broken Dockerfile fails on the pull
request instead of at tag time. CI pushes nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The prerelease policy reached the image and stopped there: the GitHub release
was created with prerelease: false and make_latest: true hardcoded, so a
v1.2.3-rc1 became the Latest release the deployment doc points commissioning
engineers at, while the image :latest correctly stayed put.

Worse, nothing held :latest to moving forward at all. Re-running an old tag's
workflow from the Actions UI republished that old version as :latest, and two
tags pushed together raced because the concurrency group carried github.ref.
A consumer tracking :latest downgraded with no signal.

So "latest" is now one decision, made once in the Read the tag step: the tag
carries no - suffix AND it is the newest release tag in the repository. The
release's prerelease and make_latest fields and the image's :latest push all
read that answer, so the release page and the registry cannot disagree, and
neither can walk backwards. The newest tag comes from git's version ordering
over unsuffixed tags, which compares numeric fields as numbers - v0.10.0
outranks v0.9.0 - and keeps git's placement of v1.0.0-rc1 after v1.0.0 out of
it. A skipped :latest push prints why. The concurrency group drops github.ref
so two tag pushes serialise rather than race.

The image build moves ahead of release creation: the build is the half that
fails on a bad copy or a bad Dockerfile, and it now fails with nothing
published to withdraw. Only login and push stay after it. The built image runs
`version` before being pushed - CI's smoke check exercises a binary built
without -trimpath and -s -w, so these are the only bytes proved. docker logout
runs on a trap, covering the skip and failure paths.

Both builds pass --platform linux/amd64, and the release job reads the manifest
back. A FROM --platform= pin was tried and does not do this: with the pin set
to arm64 and no build flag, an amd64 host still produced an amd64 image, since
scratch carries no config for the output to inherit. The image also gains
org.opencontainers.image.source, which is what links the GHCR package to this
repository.

/samples/ is ignored. It holds archive dumps from a customer installation, was
untracked and unignored in a public repository, and the image/ rule added with
the Dockerfile only kept it away from the Docker daemon, not from git add -A.
Nothing inside it is touched.

The Russian deployment doc gains the third artifact as what it is: an image for
consumers' test benches, never part of commissioning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mrcsin
mrcsin merged commit 3cd2965 into master Aug 24, 2026
2 checks passed
@mrcsin
mrcsin deleted the container-image branch August 24, 2026 08:58
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.

1 participant