Release Docker/OCI images the way goreleaser releases binaries.
stevedore builds multi-arch container images, tags them from git state, pushes to one or more registries, signs them with cosign, generates an SBOM, and writes a changelog — all from a single declarative config file.
$ stevedore release
==> building myapp
- ghcr.io/acme/myapp:1.4.0
- ghcr.io/acme/myapp:9f8e7d6
- ghcr.io/acme/myapp:latest
+ docker buildx build --platform linux/amd64,linux/arm64 ... --push .
+ cosign sign --yes ghcr.io/acme/myapp@sha256:…
+ syft ghcr.io/acme/myapp@sha256:… -o spdx-json=dist/sbom-myapp.spdx.json
+ cosign attest --yes --predicate dist/sbom-myapp.spdx.json --type spdxjson ghcr.io/acme/myapp@sha256:…
==> changelog written to dist/CHANGELOG.md
==> release completeCI pipelines for container images tend to be a pile of bespoke shell: compute a tag
from the git ref, docker buildx with the right flags, remember to sign, remember
the SBOM, hand-maintain a changelog. stevedore turns all of that into one config
file you check in — so stevedore release does the same thing on your laptop and in
CI, and every image ships signed and attested by default.
Homebrew:
brew install blairham/tap/stevedoreWith Go:
go install github.com/blairham/stevedore@latestOr run the released image:
docker run --rm -v "$PWD:/src" -w /src ghcr.io/blairham/stevedore releasestevedore orchestrates other tools rather than reimplementing them — the same design goreleaser uses — which keeps the binary tiny (~4.5 MB) instead of bundling their combined ~900 dependencies. Depending on which features you enable, you'll need:
| Tool | Needed for | Required? |
|---|---|---|
docker (with buildx) |
building & pushing | always |
git |
version/tag/changelog | always |
cosign |
signing & attestation | when sign.cosign.enabled |
syft |
SBOM generation | when sbom.enabled |
grype or trivy |
vulnerability scanning | when scan.enabled |
crane |
registry-based versioning | when versioning.strategy: registry |
aws |
ECR-based versioning | when versioning.strategy: ecr |
gh |
GitHub releases | when release.github.enabled |
Run stevedore doctor to check what's installed and get an install hint for
anything missing. release and build run this check automatically and fail fast
(before building) if a tool they need is absent.
# 1. Drop a starter config in your repo (scans your Dockerfiles)
stevedore init
# ...or import an existing setup:
# stevedore init --from goreleaser # from a .goreleaser.yaml dockers: block
# stevedore init --from bake # from a docker-bake target set
# 2. Edit .stevedore.yaml — set your repositories/owner
# 3. Validate config and preview the resolved plan
stevedore check
# 4. See exactly what would run, without doing it
stevedore release --snapshot --dry-run
# 5. Build locally for the inner loop (single platform, loaded into docker)
stevedore build
# 6. Cut a real release (clean, tagged checkout)
git tag v1.4.0
stevedore release| Command | What it does |
|---|---|
stevedore release |
Full pipeline: build all platforms → push → sign → SBOM → changelog. Requires a clean, tagged checkout unless --snapshot. |
stevedore plan |
Resolve versions, change detection, and build-once grouping — print the plan as JSON without building. The include array is GitHub Actions matrix shape (see Matrix mode). |
stevedore build |
Inner-loop build: one platform, loaded into the local docker daemon, no push. --push publishes multi-arch but skips the release extras. |
stevedore check |
Validate the config and print the fully-resolved release plan (the exact refs that would publish). |
stevedore verify <ref> |
Verify a pushed image's cosign signature, SBOM attestation, and SLSA provenance. |
stevedore doctor |
Probe for docker/buildx/git/cosign/syft/grype/crane/aws, report versions, and print install hints for anything your config requires. |
stevedore init |
Scaffold a .stevedore.yaml by scanning Dockerfiles, or --from goreleaser / --from bake to import an existing config. |
stevedore schema |
Print the JSON Schema for .stevedore.yaml (for editor autocomplete/validation). |
| Flag | Description |
|---|---|
-f, --config |
Path to config file (default: autodiscover .stevedore.yaml). |
--dir |
Project/repository root (default .). |
--dry-run |
Print every command without executing it. |
-v, --verbose |
Verbose output. |
--snapshot, --skip-sign, --skip-sbom, --skip-scan, --skip-test,
--skip-changelog, --skip-publish, --only-changed / --changed-since <ref>
(skip unchanged images — see Monorepos), and --output json
(emit a machine-readable release summary to stdout).
--only <id,…> builds just those images, unconditionally — selection was the
planner's decision, so change detection is skipped. --pin-version <id>=<ver>
(repeatable) makes the run tag exactly what the plan resolved instead of
re-resolving. Both come straight out of a stevedore plan entry (.only /
.pins); see Matrix mode.
Every release also writes <dist>/release-summary.json and, in GitHub Actions, a
job-summary table (images, digests, signed/sbom/provenance/test status, vuln
counts) to $GITHUB_STEP_SUMMARY. Each image entry carries repositories,
pushed (false under --no-push), and a reason — why it built ("src/…
since its release marker") or why it was skipped ("inputs unchanged"). Under
GitHub Actions the compact JSON is also written as a summary step output
(republished by the composite action), so workflows can drive per-image
follow-ups — e.g. deploy notifications — filtered on pushed.
stevedore schema > stevedore.schema.jsonThen add to the top of .stevedore.yaml for autocomplete + validation:
# yaml-language-server: $schema=./stevedore.schema.jsonThe set of published references is the cartesian product of repositories × tags.
Tags are Go templates rendered against git state (see Template context).
Floating tags — latest or anything ending in -latest — are treated specially:
they only publish on the default branch of a non-snapshot release. This means
latest never accidentally moves from a feature branch or a snapshot build, while
immutable tags like the version and commit SHA always publish.
Signing and SBOM generation happen by digest (repo@sha256:…), not by tag, so the
exact artifact is pinned regardless of how many mutable tags point at it.
The release version can be derived several ways via the versioning: block. The
default is git; the others let you avoid relying on git tags entirely.
| Strategy | Where the version comes from |
|---|---|
git (default) |
Git tags. Clean, tagged checkout → the tag with any leading v stripped (v1.4.0 → 1.4.0). Otherwise a snapshot like 1.4.0-SNAPSHOT-9f8e7d6 (-dirty if the tree is dirty). |
registry |
Lists the existing tags in a registry repo (via crane), takes the highest semver, and bumps it by patch/minor/major. Non-semver tags (latest, commit SHAs) are ignored. |
ecr |
Like registry, but lists tags via aws ecr describe-images using your AWS credentials directly — no crane or docker credential helper. Region is inferred from the ECR host (override with region:). |
static |
An explicit value:. |
env |
Read from an environment variable. |
command |
The trimmed stdout of a command — an escape hatch for anything. |
In a multi-image config, registry/ecr version each image independently from
its own repo, preserving per-service versions. Set repo: to pin one repo for a
single unified version instead. A repo with no semver tags starts at
versioning.initial (default 0.1.0). stevedore check never hard-fails on an
unreachable registry — it warns and shows a placeholder so the rest of the config
still validates offline.
stevedore release refuses to run on a dirty tree unless you pass --snapshot. A
git tag on HEAD is required only for the git strategy — the others source the
version elsewhere, which is handy when your tags drift out of sync with what's
actually published.
versioning:
strategy: ecr
bump: minor # patch (default) | minor | major
# region: us-east-1 # optional; inferred from the ECR host otherwise
# repo: "..." # optional; defaults to each image's own repositoryThe ecr strategy shells out to aws ecr describe-images using your AWS
credentials directly (SSO profile / env / IRSA) — no crane and no docker
credential helper. In CI, run aws-actions/configure-aws-credentials first.
If you'd rather use crane (works across ghcr/Docker Hub/ECR via the Docker
credential chain), use strategy: registry with the
amazon-ecr-credential-helper
configured. stevedore check prints the version it resolves, so you can preview the
next release without publishing anything.
stevedore is secure-by-default: every release is signed, gets an SBOM, and (when enabled) carries SLSA build provenance. The pipeline runs in this order so a bad image never gets signed or shipped:
build → scan (gate) → smoke test (gate) → sign → SBOM + attest → provenance → changelog → publish
- Scan gate —
scan.fail_onblocks the release if the built image has a vulnerability at or above the given severity (defaults tocritical). - Smoke test gate —
test.cmdruns the image and blocks the release unless it exitstest.expect_exit. Don't sign or ship an image that doesn't even start. - Signing — cosign, keyed or keyless (OIDC), always by digest.
- SBOM — syft, optionally attached to the image as a signed attestation.
- Provenance — BuildKit SLSA provenance (
mode=maxrecords the full build).
Both gates run before signing, so a failing image is never signed or published.
Verify any published image round-trips:
stevedore verify ghcr.io/acme/myapp:1.4.0 \
--certificate-identity "https://github.com/acme/myapp/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com"For a repo that builds many images, stevedore can skip images whose code didn't change. There are two modes:
| Mode | How it decides | Best for |
|---|---|---|
--changed-since <ref> |
git diff since a ref; an image builds if a changed file matches its paths | CI (stateless — compare a PR to main) |
--only-changed |
content fingerprint vs. the last release, stored in <dist>/fingerprints.json |
local iteration |
change_detection.marker_refs |
each image diffs against its own last-release git ref (refs/releases/image/<id>), advanced after each push |
CI, per-image release cadence (stateless, no ref to pass) |
With marker_refs: true, an image rebuilds iff its sources changed since its own
last release — even across independent releases and fresh checkouts. After a
successful push, stevedore advances refs/releases/image/<id> to HEAD and pushes
it, so the baseline lives in git (no state file to persist).
stevedore release --changed-since origin/main
# ==> building api
# ==> skipping worker (no matching files since origin/main)The hard case is many images built from one Dockerfile and one context (they differ only by a build arg). Without scoping, any change rebuilds everything — because every image's context is the whole repo. Declare what each image actually depends on:
change_detection:
shared_paths: # a change here rebuilds every image
- "Dockerfile"
- "*.sln"
images:
- id: rothera
build_args: ["PROJECT=Rothera"]
paths: ["Rothera/**"] # this image depends only on these (+ shared_paths)Hand-maintaining paths gets ugly once shared libraries and transitive
dependencies are involved. For .NET, let stevedore read the .csproj
<ProjectReference> graph instead:
change_detection:
resolver: dotnet
shared_paths: ["Dockerfile", "*.sln", "Directory.*"]
images:
- id: nadex-mdgw
build_args: ["PROJECT=PinPredict.NadexMdGw"]
project: PinPredict.NadexMdGw/PinPredict.NadexMdGw.csproj
# paths auto-resolved to NadexMdGw + Nadex + Fix + Shared (transitively)Now a change to a shared library rebuilds exactly the images that reference it — transitively — and nothing else.
For --only-changed, the fingerprint file lives under dist/ (git-ignored);
persist it between CI runs like a build cache (e.g. actions/cache). A fresh
checkout with no fingerprint safely rebuilds everything.
Images whose build spec is identical (same Dockerfile, context, target, platforms,
build args, and labels) and differ only by destination repository/tag are built
once and pushed to every member's tags in a single buildx invocation — no
redundant rebuilds. Images that differ by a build arg (e.g. a PROJECT=) stay
separate. Grouping is automatic; nothing to configure.
A single runner with --parallel N is fine for a handful of images, but a
change touching many heavy images wants one runner each. stevedore plan
splits deciding from building so CI can fan out:
{
"include": [
{"group": "oms", "ids": ["oms"], "only": "oms",
"versions": {"oms": "0.0.513"}, "pins": "--pin-version oms=0.0.513",
"reason": "src/PinPredict/… changed since its release marker"}
],
"skipped": [{"id": "kalshi-mdgw", "reason": "unchanged since its release marker"}]
}plan resolves per-image versions, runs change detection (marker refs,
--changed-since, or --only-changed), and applies build-once grouping — a
group is one entry, so images sharing a build still ride one runner.
Each matrix job then runs release --only <entry.only> <entry.pins>: it builds
its entry unconditionally, tags exactly what the plan resolved, and advances
only its own release markers. Progress goes to stderr; stdout is only the JSON.
jobs:
plan:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.plan.outputs.plan }}
steps:
- uses: actions/checkout@v4
with: {fetch-depth: 0}
- uses: blairham/stevedore@v1
id: plan
with: {command: plan}
build:
needs: plan
if: ${{ fromJson(needs.plan.outputs.matrix).include[0] != null }}
strategy:
matrix: ${{ fromJson(needs.plan.outputs.matrix) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: {fetch-depth: 0}
- uses: blairham/stevedore@v1
with:
command: release
args: --only ${{ matrix.only }} ${{ matrix.pins }}Entries carry the member ids, so a caller can also map per-entry metadata —
e.g. pick a per-service cloud credential/role for single-member entries.
stevedore looks for .stevedore.yaml, .stevedore.yml, stevedore.yaml, or
stevedore.yml (in that order). Unknown fields are rejected, so typos fail fast.
version: 1
project_name: myapp
default_branch: main # branch on which floating tags may publish
dist: dist # output dir for SBOMs and the changelog
images:
- id: myapp # stable identifier used in logs/artifact names
dockerfile: Dockerfile
context: .
target: "" # optional multi-stage target
platforms:
- linux/amd64
- linux/arm64
repositories: # destinations without a tag
- ghcr.io/acme/myapp
- registry.acme.io/myapp
tags: # Go templates; floating tags gated to default branch
- "{{ .Version }}"
- "{{ .ShortCommit }}"
- "latest"
build_args:
- "VERSION={{ .Version }}"
labels:
org.opencontainers.image.source: "https://github.com/acme/myapp"
org.opencontainers.image.version: "{{ .Version }}"
org.opencontainers.image.revision: "{{ .Commit }}"
org.opencontainers.image.created: "{{ .Date }}"
secrets: # BuildKit --secret entries. An env-backed secret
- id: github_token # whose variable is unset or empty is SKIPPED (like an
env: GITHUB_TOKEN # empty cache entry), so a config can declare a
# - id: npmrc # CI-minted token (e.g. a private-module fetch token)
# file: ./.npmrc # that stays inert on local builds that don't export it.
cache_from: # buildx --cache-from sources; empty-rendering
# entries are skipped, so an env-templated value enables caching only
# where the environment provides it (e.g. CI) and stays inert locally
- "type=registry,ref=ghcr.io/acme/myapp:buildcache"
# - '{{ index .Env "STEVEDORE_CACHE_FROM" }}'
cache_to: # buildx --cache-to destinations (same skip rule)
- "type=registry,ref=ghcr.io/acme/myapp:buildcache,mode=max"
# - '{{ index .Env "STEVEDORE_CACHE_TO" }}'
paths: # change-detection globs (see Monorepos); ** supported
- "services/myapp/**"
project: "" # or a .csproj to auto-derive paths from its graph
extra_flags: [] # passed verbatim to `docker buildx build`
sign:
cosign:
enabled: true
key: "" # omit for keyless (OIDC) signing
args: [] # extra cosign flags
sbom:
enabled: true
generator: syft # only syft is supported
format: spdx-json # or cyclonedx-json
attest: true # attach a signed SBOM attestation (needs cosign)
scan:
enabled: true
scanner: grype # grype (default) | trivy
fail_on: high # block the release at this severity or above;
# negligible|low|medium|high|critical. Empty = report only.
ignore: # vulnerability IDs to exclude from the gate
- CVE-2024-0000
args: [] # extra flags passed to the scanner
provenance:
enabled: true # emit a SLSA build-provenance attestation (push only)
mode: max # min | max (max records the full build definition)
test:
enabled: true # smoke-test the built image before signing/pushing
cmd: ["/usr/bin/myapp", "--version"] # run inside the container
expect_exit: 0 # required exit code
timeout: 30s # Go duration; default 60s
versioning:
strategy: git # git (default) | registry | ecr | static | env | command
# registry/ecr strategies:
# bump: patch # patch | minor | major
# repo: ghcr.io/acme/myapp # defaults to each image's own repository
# region: us-east-1 # ecr only; inferred from the ECR host otherwise
# initial: "0.1.0" # when the repo has no semver tags yet
change_detection: # scope --only-changed / --changed-since for monorepos
resolver: "" # "dotnet" auto-derives per-image paths from .csproj refs
shared_paths: # a change here rebuilds every image
- "Dockerfile"
- "*.sln"
changelog:
enabled: true
sort: asc # asc | desc
exclude: # drop commits whose subject matches any regex
- "^chore:"
- "^docs:"
- "^test:"
dependency_diff: true # append packages added/removed/upgraded since the
# previous release (diffs this SBOM vs the previous tag's)
release:
github:
enabled: true # create a GitHub release (via gh) with the changelog
draft: false
prerelease: false
announce:
slack:
enabled: true
webhook_env: SLACK_WEBHOOK # env var holding the webhook URL
template: "🚀 {{ .ProjectName }} {{ .Version }} shipped" # optional
discord:
enabled: false
webhook_env: DISCORD_WEBHOOKPublishing (release.github + announce) runs only on real releases, never on
--snapshot, and can be turned off per-run with --skip-publish.
Tags, labels, and build args are rendered with Go's text/template. Referencing an
undefined field is an error (no silent empty strings). Available fields:
| Field | Example |
|---|---|
.ProjectName |
myapp |
.Version |
1.4.0 |
.Tag |
v1.4.0 |
.Commit |
full SHA |
.ShortCommit |
9f8e7d6 |
.Branch |
main |
.Date |
RFC 3339 build time (UTC) |
.Timestamp |
Unix seconds |
.IsSnapshot |
true in a snapshot build |
.IsDefault |
true when HEAD is on the default branch |
.Env.NAME |
environment variable NAME |
Helper functions: lower, upper, trim, replace, trimPrefix, trimSuffix.
When enabled, stevedore reads commits since the previous tag and groups them by
Conventional Commit type into Features,
Bug Fixes, Performance, Refactors, Documentation, and Other. A
! (e.g. feat!:) marks a breaking change. Non-conforming subjects land under
"Other". The result is written to <dist>/CHANGELOG.md.
stevedore does the same thing locally and in CI. The easiest way is the bundled GitHub Action, which installs stevedore and every tool it needs (cosign, syft, grype, and optionally crane) for you:
name: release
on:
push:
tags: ["v*"]
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write # create the GitHub release
packages: write # push to ghcr.io
id-token: write # keyless cosign signing
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # tags + history for versioning/changelog
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: blairham/stevedore@v1 # installs stevedore + cosign/syft/grype
with:
command: release| Input | Default | Description |
|---|---|---|
command |
release |
Subcommand: release, build, check, verify, doctor. |
args |
"" |
Extra args, e.g. --snapshot --only-changed. |
config |
autodiscover | Path to the config file. |
version |
latest |
stevedore version to install. |
working-directory |
. |
Directory to run in. |
install-cosign / install-syft / install-grype |
true |
Install that tool. |
install-crane |
false |
Install crane (enable for versioning.strategy: registry). |
A pull-request check that validates the plan without publishing:
- uses: blairham/stevedore@v1
with:
command: checkPrefer to wire the tools up yourself? stevedore is just a binary, so
go run github.com/blairham/stevedore@latest release after installing the tools
works too. Either way, fetch-depth: 0 matters: shallow clones hide the tags and
history stevedore needs to derive the version and build the changelog.
go test ./... # unit tests
go vet ./...
stevedore release --snapshot --dry-run # dogfood: stevedore releases itselfReleases use two tools, one per artifact kind: stevedore builds and publishes
its own container image (dogfooding), and GoReleaser (.goreleaser.yaml)
publishes the CLI binary, the GitHub release, and the Homebrew cask. See
.github/workflows/release.yml.
MIT