Building And Release Artifacts
How to produce distributable binaries, packaged web assets, and Docker images.
Everything is driven by make; there is no separate release script.
Components
| Component | Go package | Ships as |
|---|---|---|
| Backend | ./cmd/backend | binary (web UI embedded) + Docker image |
| Ephemeral runner | ./cmd/runner/ephemeral | binary + Docker image |
| GitHub runner-group | ./cmd/runner/group/github | binary + Docker image |
| Docker runner-group | ./cmd/runner/group/docker | binary + Docker image |
| Local runner-group | ./cmd/runner/group/local | binary only |
CLI (gantrycli) | ./cmd/cli | binary |
| Web UI | web/ (vite) | embedded in the backend; also a standalone tarball |
The local runner-group has no Docker image: it launches ephemeral runners as
bash child processes, which a minimal container cannot host. Run it as a
binary on a host that has bash, git, and the ephemeral runner available.
Embedded web UI
The backend serves the web UI from the same binary — there is no separate static host.
web/builds with vite intoweb/dist/.make build_webruns the vite build and syncsweb/dist/intointernal/backend/webui/dist/, the directory embedded via//go:embedininternal/backend/webui/webui.go.internal/backend/webui/dist/.gitkeepis committed sogo build ./cmd/backendcompiles on a fresh checkout before the UI has ever been built; the built assets themselves are gitignored.webui.Handler()is mounted on/ininternal/backend/server/router.go.ServeMuxkeepsGET /healthand the/api/v1/...routes ahead of it; an/api/guard 404s unknown API paths so they never fall through to the SPA shell. Any other unmatched path servesindex.htmlfor client-side routing.
A backend built without first building the UI still runs — / returns 404
until make build_web (or a release build) populates the embed directory.
Binary / web artifacts (dist/)
make build_artifacts_* cross-compiles static (CGO_ENABLED=0) binaries and
packages each into the top-level dist/ directory. Every filename carries the
VERSION tag (dev by default) — set it to stamp a release:
make build_artifacts_all VERSION=v1.2.3
| Target | Output (VERSION = <v>) |
|---|---|
build_artifacts_backend | dist/backend-<v>-linux-{amd64,arm64}.tar.gz |
build_artifacts_runner | dist/runner-<v>-linux-{amd64,arm64}.tar.gz |
build_artifacts_runner_group_github | dist/runner-group-github-<v>-linux-{amd64,arm64}.tar.gz |
build_artifacts_runner_group_docker | dist/runner-group-docker-<v>-linux-{amd64,arm64}.tar.gz |
build_artifacts_runner_group_local | dist/runner-group-local-<v>-linux-{amd64,arm64}.tar.gz |
build_artifacts_web | dist/web-<v>.tar.gz |
build_artifacts_cli | dist/cli-<v>-{linux,darwin}-{amd64,arm64}.tar.gz, dist/cli-<v>-windows-{amd64,arm64}.zip |
build_artifacts_all | all of the above |
VERSION is the single release identifier used for artifact filenames, binary
link-time stamping, and Docker image tags. Backend/runner artifacts are
linux-only (amd64 + arm64). The CLI is the exception — it is built for
linux, macOS, and Windows; Windows archives are .zip, everything else is
.tar.gz. build_artifacts_backend runs the web build first so the binary
embeds the UI.
Version stamping
Every binary reports the same version through the shared internal/version
package. Its Version value defaults to dev and is overridden at link time
by all build targets (build_artifacts_*, build_docker_*, build_cli) via:
-ldflags "-X github.com/gantrycd/gantrycd/internal/version.Version=$(VERSION)"
So make build_cli VERSION=v1.2.3 yields a binary where gantrycli --version
prints v1.2.3; the backend and runners log the same value at startup. Docker
images receive it through a VERSION build arg the build_docker_* targets
pass automatically. A plain go build / go run leaves it at dev.
Release builds (build_artifacts_* and the Docker images) additionally pass
-s -w to strip the symbol table and DWARF debug info, shrinking the binary by
roughly a quarter. Go panic stack traces stay readable — the runtime keeps its
own symbol metadata — but external debuggers (delve, gdb) and symbol tools
lose information. The dev build_cli target deliberately omits -s -w so the
local binary stays debuggable.
Every archive is written with a <archive>.sha256 checksum sidecar next to it.
Verify integrity with shasum (run from dist/ so the recorded basename
resolves):
cd dist && shasum -a 256 -c backend-v1.2.3-linux-amd64.tar.gz.sha256
Downloading and verifying a release
When fetching an archive with curl, gate extraction on the checksum so a
tampered download is never unpacked. The hash can only be confirmed once the
whole file has arrived, so the archive must land on disk first — but &&
chaining stops before tar runs if the check fails:
base=https://github.com/acme/gantrycd/releases/download/v1.2.3
f=backend-v1.2.3-linux-amd64.tar.gz
curl -fsSLO "$base/$f" \
&& curl -fsSL "$base/$f.sha256" | shasum -a 256 -c - \
&& tar -xvzf "$f"
shasum -a 256 -c - reads the checksum line from stdin and exits non-zero on a
mismatch. On Linux without shasum, use GNU coreutils’ sha256sum -c - — the
file format is identical.
Piping curl straight into tar skips the disk write but can only detect a
bad archive after tar has already extracted it, so it is not a safe substitute
for the gated form above.
Docker images
make build_docker_* builds images via docker buildx. All images are
multi-stage; the backend and runner-group images use a scratch final stage
(only the binary plus CA certs copied from the golang build stage). The
ephemeral runner image is debian:stable-slim because the runner shells out to
tenv, tofu, git, and bash at runtime — tenv is preinstalled. That
image also carries the lifecycle-hook tooling (conftest, opa, infracost,
jq, yq), pinned by *_VERSION build args and fetched in a throwaway stage
that gates every download on the publisher’s SHA-256, so the archives never
reach a shipped layer. The fetch stage runs on $BUILDPLATFORM and selects
artifacts by $TARGETARCH, keeping cross-arch builds out of QEMU.
| Target | Component |
|---|---|
build_docker_backend | backend |
build_docker_runner | runner |
build_docker_runner_group_github | runner-group-github |
build_docker_runner_group_docker | runner-group-docker |
build_docker_all | all of the above |
Dockerfiles live next to each entrypoint (cmd/backend/Dockerfile,
cmd/runner/ephemeral/Dockerfile,
cmd/runner/group/{github,docker,kubernetes}/Dockerfile, cmd/cli/Dockerfile)
plus schema/Dockerfile for the migration image. Most runtime images use
dist/ as the build context because they only assemble prebuilt artifacts; the
schema image uses the repo root so it can copy schema and migration files.
Naming, registries, and tags
Image references are built from the configured prefixes and the single
VERSION tag:
| Variable | Default | Meaning |
|---|---|---|
IMAGE_PREFIXES | gantrycd | Registries / namespaces prepended to each component name |
VERSION | dev | Artifact version and Docker tag |
DOCKER_CACHE_DIR | empty | Optional local directory for BuildKit cache refs |
Each build_docker_* target builds the image once and tags it with every
<prefix>-<component>:<VERSION> ref, so a single build can push the same
version to multiple registries:
make build_docker_all PUSH=1 \
IMAGE_PREFIXES="ghcr.io/acme/gantrycd docker.io/acme/gantrycd" \
VERSION=v1.2.3
These variables can also be set in .env (the Makefile does include .env),
since they are declared with ?=.
Local builds vs. multi-arch
A multi-arch image manifest cannot be loaded into the local Docker image store,
so the default is convenient for development: each build_docker_* target
builds and --loads only the host architecture.
To produce a real multi-arch manifest, set PUSH=1. The build then covers
every DOCKER_PLATFORMS architecture (linux/amd64,linux/arm64 by default)
and pushes every ref to its registry. Point IMAGE_PREFIXES at real
registries first (and docker login to each).
buildx_setup (an implicit prerequisite of every build_docker_* target)
creates the shared gantrycd-buildx builder once; it is idempotent.
Set DOCKER_CACHE_DIR to a local path to persist BuildKit cache layers between
builds. Each component imports from <dir>/<component> and exports to a fresh
<dir>/<component>-next directory, then replaces the old cache after a
successful build. CI stores this directory with actions/cache.
Override IMAGE_PREFIXES, VERSION, DOCKER_PLATFORMS, and
DOCKER_CACHE_DIR as needed. The ephemeral image’s tenv release is pinned in
its Dockerfile.
Set PROVENANCE= to override buildx’s provenance attestation: PROVENANCE=false
skips it (useful for registries that reject the OCI image index buildx generates
to carry the attestation), PROVENANCE=mode=max emits the full attestation.
Unset = buildx default.
Everything at once
make build_artifacts_all— every binary and web artifact intodist/.make build_docker_all— every Docker image.make build_all—build_artifacts_allthenbuild_docker_all.