Skip to content

Commit 1c3dc41

Browse files
committed
Publish snapshot (2026-08-04)
1 parent 37956bf commit 1c3dc41

28 files changed

Lines changed: 2413 additions & 746 deletions
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
# Builds the three RAMP service images and publishes them to the GitHub
2+
# Container Registry.
3+
#
4+
# WHAT STARTS IT. A tag whose name begins with "v", pushed to this repository,
5+
# or a manual run from the Actions tab. Publishing source and publishing images
6+
# are deliberately separate acts: a source snapshot on its own builds nothing,
7+
# so a documentation-only update never reissues the images.
8+
#
9+
# A manual run must select a TAG as its ref, not a branch. The version comes from
10+
# the tag and there is nowhere else to get it; a branch produces no version at
11+
# all, and the first step below stops the run rather than letting it fail later
12+
# with something less obvious.
13+
#
14+
# AUTHENTICATION. The per-run GITHUB_TOKEN and nothing else. No personal access
15+
# token is created, stored or rotated for this workflow.
16+
#
17+
# TAGS. Exactly one per build: the version from the git tag with the leading "v"
18+
# removed. There is no "latest" and no moving major or minor pointer, so a
19+
# published tag names one build for ever. Production should pin by digest
20+
# (@sha256:...), which every image carries whether or not it is tagged.
21+
#
22+
# ARCHITECTURE. linux/amd64 only. The Broker and the Identity service are pure
23+
# Go and would need no more than the extra platform here, but the Exchange links
24+
# a C library for the ledger client, so an x86 runner cannot build its arm64
25+
# variant. Adding ARM means an arm64 runner and a manifest merge.
26+
#
27+
# Third-party actions are pinned by commit rather than by tag, because whoever
28+
# owns an action can repoint its tags at any time.
29+
30+
name: Publish container images
31+
32+
on:
33+
push:
34+
tags:
35+
- "v*"
36+
workflow_dispatch:
37+
38+
permissions:
39+
contents: read
40+
packages: write
41+
42+
concurrency:
43+
# One publish per ref at a time, and never cancel a run that is under way. A
44+
# half-finished release leaves some of the three images published and some
45+
# not, which is worse than a slow queue.
46+
group: publish-images-${{ github.ref }}
47+
cancel-in-progress: false
48+
49+
jobs:
50+
publish:
51+
runs-on: ubuntu-24.04
52+
strategy:
53+
# The three build independently. One failure must not hide the other two
54+
# results, which is exactly what you need when a release breaks.
55+
fail-fast: false
56+
matrix:
57+
include:
58+
- service: exchange
59+
dockerfile: src/exchange/Dockerfile
60+
- service: broker
61+
dockerfile: src/broker/Dockerfile
62+
- service: identity
63+
dockerfile: src/identity/Dockerfile
64+
65+
steps:
66+
- name: Check out the source
67+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
68+
69+
- name: Set up Buildx
70+
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
71+
72+
- name: Sign in to the registry
73+
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
74+
with:
75+
registry: ghcr.io
76+
username: ${{ github.actor }}
77+
password: ${{ secrets.GITHUB_TOKEN }}
78+
79+
- name: Derive the image tag
80+
id: meta
81+
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
82+
with:
83+
# Lower case throughout: the registry refuses a mixed-case name.
84+
images: ghcr.io/ramp-protocol/${{ matrix.service }}
85+
# This list REPLACES the action's default rules. Keep it to the single
86+
# entry: a default rule would publish tags that move.
87+
tags: |
88+
type=semver,pattern={{version}}
89+
# Needed as well. The "latest" flavour defaults to "auto", which adds
90+
# a latest tag on its own as soon as the ref is a version tag.
91+
flavor: |
92+
latest=false
93+
94+
- name: Refuse a run that has no version to publish
95+
# A manual run can select any ref. On a branch the semver rule above
96+
# matches nothing, so the tag list comes out empty. Without this step the
97+
# build compiles all the way through and then fails inside the push, and
98+
# the message it fails with does not say "you picked a branch".
99+
#
100+
# The ref reaches the script through the environment, never through an
101+
# expression. GitHub substitutes an expression into the script TEXT
102+
# before bash reads it, so at that point a ref name is command text.
103+
# A ref name may hold $, backticks, parentheses, ; & and |. git does
104+
# reject some characters — spaces, backslashes, control characters, and
105+
# the revision syntax it needs for itself, : ? * [ ~ ^ — but not one of
106+
# those omissions closes this hole. The job holds a token with
107+
# packages: write for all three images.
108+
#
109+
# The message names no version. The tag is the only source of one, so an
110+
# example here would be a second place to edit at release time, and the
111+
# last one anybody would think to check.
112+
if: steps.meta.outputs.tags == ''
113+
env:
114+
TRIGGER_REF: ${{ github.ref }}
115+
run: |
116+
echo "::error::No version tag was derived from ${TRIGGER_REF}. The version comes from the tag name and nowhere else: run this workflow against a tag whose name is the version to publish, with a leading v."
117+
exit 1
118+
119+
- name: Build and push
120+
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
121+
with:
122+
# The repository root, because every service Dockerfile also copies
123+
# the shared internal/ tree.
124+
context: .
125+
file: ${{ matrix.dockerfile }}
126+
platforms: linux/amd64
127+
push: true
128+
tags: ${{ steps.meta.outputs.tags }}
129+
# Only the two labels that cannot be known until the build runs. The
130+
# rest — source, licenses, title, description — are baked into each
131+
# Dockerfile, and passing the metadata action's full label set here
132+
# would overwrite the per-service title and description with this
133+
# repository's own name for all three images. There is no created
134+
# label because the image config already records a creation time.
135+
labels: |
136+
org.opencontainers.image.version=${{ steps.meta.outputs.version }}
137+
org.opencontainers.image.revision=${{ github.sha }}
138+
# Off on purpose. With provenance on, the pushed tag resolves to an
139+
# image index carrying an extra attestation entry, which is a
140+
# multi-platform manifest in everything but name. One platform is
141+
# published, so the tag should resolve straight to that one image.
142+
provenance: false
143+
# Without a cache every run recompiles Go from cold, several minutes
144+
# per service. The scope keeps the three from evicting each other.
145+
cache-from: type=gha,scope=${{ matrix.service }}
146+
cache-to: type=gha,mode=max,scope=${{ matrix.service }}

Makefile

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
.PHONY: go-fmt go-fmt-check go-lint go-typecheck go-test test-integration test-zitadel test-e2e-collect
33
.PHONY: db-up db-down db-logs dev-keys adr-001-check adr-008-d3-check sdk-pin-check
44
.PHONY: stale-proto-names-check published-refs-check published-secrets-check test-terraform
5+
.PHONY: image-version-check
56
.PHONY: zitadel-up zitadel-creds zitadel-logs zitadel-down
67
.PHONY: edge-%
78
.PHONY: test-e2e e2e-keys e2e-up e2e-down e2e-logs e2e-demo
@@ -33,15 +34,15 @@ help:
3334
@echo ""
3435
@echo "Per-subproject: make edge-<target> (e.g. edge-lint, edge-test)"
3536

36-
quality: fmt lint typecheck test-fast jscpd file-length adr-001-check adr-008-d3-check sdk-pin-check stale-proto-names-check published-refs-check published-secrets-check
37+
quality: fmt lint typecheck test-fast jscpd file-length adr-001-check adr-008-d3-check sdk-pin-check stale-proto-names-check published-refs-check published-secrets-check image-version-check
3738
@echo "All quality gates passed!"
3839

3940
# CI variant of `quality`: formatting is CHECKED, never written. CI must not
4041
# mutate the tree, and a check FAILS on violations instead of the local `fmt`
4142
# silently auto-fixing them. (Go + edge formatting is also enforced by their
4243
# linters; tests/e2e Python formatting is enforced ONLY here.) Local
4344
# `make quality` keeps auto-formatting via `fmt`.
44-
quality-ci: fmt-check lint typecheck test-fast jscpd file-length adr-001-check adr-008-d3-check sdk-pin-check stale-proto-names-check published-refs-check published-secrets-check
45+
quality-ci: fmt-check lint typecheck test-fast jscpd file-length adr-001-check adr-008-d3-check sdk-pin-check stale-proto-names-check published-refs-check published-secrets-check image-version-check
4546
@echo "All quality gates passed (CI, check-only fmt)!"
4647

4748
file-length:
@@ -90,6 +91,13 @@ published-refs-check:
9091
published-secrets-check:
9192
@scripts/check-published-secrets.sh
9293

94+
# Keeps the published image version declared once per deployment document and
95+
# identical across the three. A release edits one line per document; a partial
96+
# edit would otherwise leave a document naming a tag nobody published, which
97+
# reads as correct right up to the "not found".
98+
image-version-check:
99+
@scripts/check-image-version.sh
100+
93101
fmt: go-fmt
94102
@$(MAKE) -C src/edge fmt
95103
@$(MAKE) -C tests/e2e fmt

docker-compose.e2e.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -919,6 +919,13 @@ services:
919919
# installing the CLI keeps the runner image small and avoids pinning a
920920
# client version against whatever daemon the host happens to run.
921921
- /var/run/docker.sock:/var/run/docker.sock
922+
# The revocation e2e writes revocations.json through the harness tree, and
923+
# the broker service above reads THE SAME host directory at /revocations
924+
# (BROKER_REVOCATION_FILE). The mount is what makes the write cross the
925+
# container boundary. Without it the path resolves inside the runner
926+
# image's own layer — the directory is there, because COPY brought
927+
# .gitkeep in, so the write succeeds and the Broker never sees a byte.
928+
- ./tests/e2e/harness/revocations:/runner/harness/revocations
922929
depends_on:
923930
exchange:
924931
condition: service_started
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
# ADR-024 — Container Image Distribution
2+
3+
**Status:** Accepted (2026-08-03)
4+
**Relates to:** ADR-020 (RAMP SDK layered libraries) — the same question asked about library packages rather than container images.
5+
6+
---
7+
8+
## Context
9+
10+
Three services of this implementation ship as containers: the Exchange, the Broker
11+
and the Identity service. Each has a Dockerfile under `src/<service>/` and a
12+
deployment document that tells an operator how to run it.
13+
14+
Until this decision, nothing was published anywhere. Two of those three deployment
15+
documents nonetheless stated that the image was available from a public registry,
16+
and named one we do not use. The image names they printed were unqualified, so a
17+
reader who copied them resolved a registry we have never pushed to and got "not
18+
found". The documents promised an artifact that did not exist, in a place it would
19+
never have been.
20+
21+
Two things are deliberately outside this decision:
22+
23+
- **The Edge worker.** It deploys as a Cloudflare Worker, not a container. Its
24+
Dockerfile is a local test harness for the e2e stack and is not a deliverable.
25+
- **Postgres, Redis, TigerBeetle, Vault and the identity provider.** These are
26+
upstream images pulled from their own publishers, pinned by us, built by them.
27+
28+
Two other push paths for these same three images already exist, and neither is what
29+
this decision governs. `scripts/ecr-push.sh` pushes to a private AWS registry to roll
30+
the demo deployment. `deploy/terraform/scripts/build-push-images.sh` pushes to
31+
whichever registry a staging stack is configured with. Both are deployment plumbing:
32+
one environment, one audience, a registry chosen per deployment. This decision is
33+
about the public artifacts of a reference implementation, where the audience is
34+
anyone and the guarantees have to hold without us being in the room. All three
35+
coexist and none shares a naming scheme with the others.
36+
37+
## Decision
38+
39+
### D1 — Images are published to the GitHub Container Registry, under flat names
40+
41+
`ghcr.io/ramp-protocol/exchange`, `ghcr.io/ramp-protocol/broker`,
42+
`ghcr.io/ramp-protocol/identity`. Not nested under the repository name.
43+
44+
The source already lives on GitHub, so the registry is part of the same account and
45+
the build needs no credential that anyone has to hold, store or rotate (see D4).
46+
Names are flat because the service is the unit an operator deploys; which repository
47+
it was built from is recorded in the image's `org.opencontainers.image.source` label
48+
instead. That label carries weight: it is what links the published package to its
49+
repository, and linking is what makes the package manageable and publishable at
50+
all.
51+
52+
Registry names must be lower case, so the organisation appears as `ramp-protocol`.
53+
54+
### D2 — One immutable version tag per build, and no `latest`
55+
56+
Each build publishes exactly one tag: the version, with no leading `v`. No `latest`,
57+
and no moving major or minor pointer such as `1.0`. A published tag names one build
58+
and is never repointed.
59+
60+
A `latest` may be introduced later, once there is a release history for it to point
61+
at. Nothing in this decision prevents that.
62+
63+
The immediate consequence is intended: `docker pull ghcr.io/ramp-protocol/exchange`
64+
with no tag **fails**. An operator who omits the version finds out immediately,
65+
rather than silently running whatever was pushed most recently.
66+
67+
Production should deploy the digest, not the tag. Every image has a `@sha256:...`
68+
address whether or not it is tagged. Our tags are written once and never moved, but
69+
that is a policy of ours; a digest is content-addressed and cannot be moved by
70+
anyone. Where the two disagree, the digest is the one that is a guarantee.
71+
72+
### D3 — `linux/amd64` only
73+
74+
No arm64 variant, and no multi-architecture manifest. The provenance attestation
75+
that the build tooling would otherwise attach is switched off for the same reason:
76+
it turns the published tag into an image index, which is a multi-platform manifest
77+
in all but name, and only one platform is published.
78+
79+
What an ARM build would cost, for whoever revisits this. The Broker and the Identity
80+
service are pure Go with cgo disabled, and would need no more than the extra platform
81+
in the build. The Exchange is different: it links a C library for the TigerBeetle
82+
ledger client, and cgo cannot cross-compile without a cross toolchain, so an x86
83+
runner cannot produce its arm64 variant at all. The clean route is a native arm64
84+
runner plus a manifest merge. That is real work, and no target deployment needs it
85+
today.
86+
87+
### D4 — The build runs in the public repository, authenticated by its per-run token
88+
89+
`.github/workflows/publish-images.yml` builds all three images and pushes them,
90+
signing in to the registry with the token GitHub issues for the run. No personal
91+
access token is created, stored, or rotated for this purpose, and the workflow
92+
contains nothing secret.
93+
94+
The workflow is authored **here**, in the source repository, and travels to the
95+
public repository through the publish allowlist in `scripts/published-paths.sh`. It
96+
cannot be authored on the public repository instead: the publish rebuilds that tree
97+
from the allowlist and deletes everything it does not reproduce, so a workflow
98+
created there would survive exactly until the next snapshot.
99+
100+
### D5 — Publishing the source and publishing the images are separate acts
101+
102+
A snapshot of the source builds nothing. The workflow listens for version tags only,
103+
and the publish pushes a branch. Pushing a version tag to the public repository is
104+
what produces images.
105+
106+
The separation is the point. Documentation-only snapshots are frequent, and each one
107+
would otherwise reissue three images with a new build date and a new digest for
108+
unchanged code. Deciding to publish source and deciding to publish binaries are
109+
different decisions and should stay two commands.
110+
111+
## Consequences
112+
113+
**Positive.**
114+
115+
- An operator pulls an artifact instead of compiling one, and needs no Go toolchain,
116+
no C toolchain, and no copy of the source to run the platform.
117+
- Nothing to leak. There is no long-lived registry credential anywhere, so there is
118+
none to rotate and none to lose.
119+
- The absence of `latest` removes a whole class of "which version is production
120+
actually running" incidents, at the cost of making every example more verbose.
121+
- Every image is traceable back to the commit it was built from through the
122+
`revision` and `version` labels the workflow stamps at build time.
123+
124+
**Negative.**
125+
126+
- Anyone on an ARM machine runs these under emulation. That is fine for inspection
127+
and wrong for measurement, and every deployment document has to say so.
128+
- The allowlist entry that carries the workflow copies the whole `.github` subtree.
129+
Anything added there later reaches the public repository with no further decision.
130+
The allowlist file records this; per-file granularity would have to be built if it
131+
ever matters.
132+
- Publishing stays ours. Whoever develops the protocol publishes the images, so a
133+
third party who wants a different registry has to build their own.
134+
135+
## Non-goals
136+
137+
- **Signing and attestation.** Images are not signed, and no SBOM is published. Both
138+
are reasonable next steps and neither is a prerequisite for a first release.
139+
- **A support or deprecation policy for published versions.** Nothing here commits to
140+
keeping any version pullable for any period.
141+
- **Publishing the Edge worker as a container.** It is not deployed that way.
142+
143+
## Rejected alternatives
144+
145+
**A public Docker Hub organisation.** It is where the deployment documents already
146+
pointed, so it looked like the smallest change. It needs an organisation account, a
147+
credential stored as a repository secret, and someone to own rotating it — all to
148+
put the artifact somewhere other than where its source already is. Rate limits on
149+
anonymous pulls are a second, smaller reason against.
150+
151+
**Reusing the private AWS registry.** It already builds and pushes these three
152+
images. It is private, tied to one cloud account, and the images it holds are named
153+
for a demo deployment. Making it public would mean publishing an operational
154+
registry, which conflates our environment with the reference implementation's
155+
artifacts.
156+
157+
**Nesting the image names under the repository**
158+
(`ghcr.io/ramp-protocol/reference-implementation/exchange`). This is the registry's
159+
default shape and needs no decision. It reads as though the repository were part of
160+
the artifact's identity, and it makes every command in every deployment document
161+
longer for no gain to the reader.

0 commit comments

Comments
 (0)