|
| 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