Skip to content
GantryCD

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

ComponentGo packageShips as
Backend./cmd/backendbinary (web UI embedded) + Docker image
Ephemeral runner./cmd/runner/ephemeralbinary + Docker image
GitHub runner-group./cmd/runner/group/githubbinary + Docker image
Docker runner-group./cmd/runner/group/dockerbinary + Docker image
Local runner-group./cmd/runner/group/localbinary only
CLI (gantrycli)./cmd/clibinary
Web UIweb/ (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 into web/dist/.
  • make build_web runs the vite build and syncs web/dist/ into internal/backend/webui/dist/, the directory embedded via //go:embed in internal/backend/webui/webui.go.
  • internal/backend/webui/dist/.gitkeep is committed so go build ./cmd/backend compiles on a fresh checkout before the UI has ever been built; the built assets themselves are gitignored.
  • webui.Handler() is mounted on / in internal/backend/server/router.go. ServeMux keeps GET /health and 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 serves index.html for 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
TargetOutput (VERSION = <v>)
build_artifacts_backenddist/backend-<v>-linux-{amd64,arm64}.tar.gz
build_artifacts_runnerdist/runner-<v>-linux-{amd64,arm64}.tar.gz
build_artifacts_runner_group_githubdist/runner-group-github-<v>-linux-{amd64,arm64}.tar.gz
build_artifacts_runner_group_dockerdist/runner-group-docker-<v>-linux-{amd64,arm64}.tar.gz
build_artifacts_runner_group_localdist/runner-group-local-<v>-linux-{amd64,arm64}.tar.gz
build_artifacts_webdist/web-<v>.tar.gz
build_artifacts_clidist/cli-<v>-{linux,darwin}-{amd64,arm64}.tar.gz, dist/cli-<v>-windows-{amd64,arm64}.zip
build_artifacts_allall 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.

TargetComponent
build_docker_backendbackend
build_docker_runnerrunner
build_docker_runner_group_githubrunner-group-github
build_docker_runner_group_dockerrunner-group-docker
build_docker_allall 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:

VariableDefaultMeaning
IMAGE_PREFIXESgantrycdRegistries / namespaces prepended to each component name
VERSIONdevArtifact version and Docker tag
DOCKER_CACHE_DIRemptyOptional 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 into dist/.
  • make build_docker_all — every Docker image.
  • make build_allbuild_artifacts_all then build_docker_all.