1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
name: Release
# Publishes brain-edge when a version tag is pushed, to two places:
# • crates.io — the library + `brain-edge` binary, for `cargo install`
# • ghcr.io — the self-host container image, how most people run it
#
# The tag normally comes from `release-please.yml`, not from a shell: merging
# the Release PR it maintains bumps the version in Cargo.toml, writes the
# CHANGELOG, and creates the tag. Tagging by hand still works and is the
# escape hatch:
#
# git tag v0.1.0 && git push origin v0.1.0
#
# The version check below is what makes both paths safe — a tag that disagrees
# with the manifest cannot publish.
#
# Auth: crates.io is OIDC **Trusted Publishing** (no long-lived token in this
# repo); GHCR uses the workflow's own `GITHUB_TOKEN` with `packages: write`,
# so there is no registry secret to rotate for either.
#
# ONE-TIME registry setup (done on crates.io, not here) — register this repo +
# this workflow as a Trusted Publisher:
# crates.io → crate settings → Trusted Publishing → GitHub:
# owner arc-labs-ai, repo brain-edge, workflow release.yml, env release
# Until that is configured the crates.io job fails closed (it never falls back
# to a token). GHCR needs no setup beyond the package being owned by the org.
#
# ORDERING: brain-edge depends on `brain-db-sdk`, which must already be on
# crates.io at a matching version or `cargo publish` fails — crates.io refuses a
# crate whose dependencies it cannot resolve. brain-db-sdk 0.1.0 is published,
# and Cargo.toml now depends on it by version rather than by path, so both jobs
# resolve it from the registry like any other dependency.
#
# The two jobs are independent — crates.io failing does not block the image,
# and vice versa. Both registries reject re-publishing the same version, so
# re-running after a partial success is a no-op for whichever already landed.
#
# The `release` environment gates both publishes; require reviewers on it
# (Settings → Environments → release) to add a manual approval before anything
# goes public.
on:
push:
tags:
- "v*"
# Escape hatch. A re-run replays the workflow as it existed AT THE TAG, so a
# workflow bug fixed afterwards cannot be recovered by re-running — v0.1.0's
# image published with no provenance because the job lacked
# `attestations: write`, and re-running would have replayed the same missing
# permission. A dispatch runs the workflow from the default branch against an
# existing tag, so fixes apply.
#
# Safe to re-run: both registries reject a version they already hold, so this
# is a no-op for whatever already published.
workflow_dispatch:
inputs:
tag:
description: "Existing version tag to publish (e.g. v0.1.0)"
required: true
type: string
# No repo-wide write; each job opts into exactly the scope it needs.
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
# Resolved once, read by every job. On a tag push github.ref_name is already
# `vX.Y.Z`; on a dispatch it would be the branch the hatch was fired from,
# which would make each job compare a branch name against a crate version.
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
jobs:
crates-io:
name: Publish → crates.io
runs-on: ubuntu-26.04
environment: release
permissions:
contents: read
id-token: write # OIDC → crates.io trusted publishing
steps:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.tag || github.ref }}
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: brain-edge
- name: Verify tag matches crate version
run: |
TAG="${RELEASE_TAG#v}"
CRATE="$(grep -m1 '^version' Cargo.toml | sed 's/.*"\(.*\)".*/\1/')"
echo "tag=$TAG crate=$CRATE"
test "$TAG" = "$CRATE" || { echo "::error::tag $TAG != crate version $CRATE"; exit 1; }
- name: Test
run: cargo test --all-targets
# Exchanges the GitHub OIDC token for a short-lived crates.io token.
- name: Authenticate to crates.io (OIDC)
id: auth
uses: rust-lang/crates-io-auth-action@v1
- name: Publish
run: cargo publish
env:
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
# Each platform builds on its own NATIVE runner rather than cross-building
# arm64 under QEMU on one machine. v0.1.0 spent 21 minutes in a single
# emulated build; brain-db splits the same way and documents why — a Rust
# workspace with native deps compiled under QEMU goes from minutes to hours.
# ubuntu-26.04-arm is free for public repos.
#
# Each job pushes BY DIGEST only; `merge` assembles the two digests into one
# manifest list, applies the real tags, and attests the result.
image-build:
name: Build image (${{ matrix.platform }})
runs-on: ${{ matrix.runner }}
environment: release
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-26.04
- platform: linux/arm64
runner: ubuntu-26.04-arm
permissions:
contents: read
packages: write # push to ghcr.io
steps:
- uses: actions/checkout@v7
with:
ref: ${{ inputs.tag || github.ref }}
- name: Verify tag matches crate version
run: |
TAG="${RELEASE_TAG#v}"
CRATE="$(grep -m1 '^version' Cargo.toml | sed 's/.*"\(.*\)".*/\1/')"
echo "tag=$TAG crate=$CRATE"
test "$TAG" = "$CRATE" || { echo "::error::tag $TAG != crate version $CRATE"; exit 1; }
- name: Derive platform tag pair
run: |
platform="${{ matrix.platform }}"
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
- uses: docker/setup-buildx-action@v4
- name: Log in to ghcr.io
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Labels are a per-image-config property, so they are applied here rather
# than only on the manifest list. brain-db shipped a release with empty
# version/revision/source because they were set only in the merge job.
- name: Derive image labels
id: meta
uses: docker/metadata-action@v6
with:
images: ghcr.io/arc-labs-ai/brain-edge
tags: type=semver,pattern={{version}},value=${{ env.RELEASE_TAG }}
labels: |
org.opencontainers.image.title=brain-edge
org.opencontainers.image.description=The HTTP/JSON edge for the Brain memory database
org.opencontainers.image.licenses=Apache-2.0
- name: Build and push by digest
id: build
uses: docker/build-push-action@v7
with:
context: .
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=ghcr.io/arc-labs-ai/brain-edge,push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ matrix.platform }}
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
# The native runner means the just-pushed digest can be started here. CI
# already smoke-tests the image it builds; this does the same for the one
# that actually ships. /health/live is the static liveness route, so it
# needs no Brain behind it.
- name: Smoke-test the pushed image
run: |
set -euo pipefail
IMG="ghcr.io/arc-labs-ai/brain-edge@${{ steps.build.outputs.digest }}"
docker run -d --name smoke -p 8080:8080 "$IMG"
for i in $(seq 1 30); do
if curl -fsS http://127.0.0.1:8080/health/live >/dev/null 2>&1; then
echo "edge is live"; docker rm -f smoke; exit 0
fi
if [ "$(docker inspect -f '{{.State.Status}}' smoke)" != "running" ]; then
echo "::error title=Smoke test failed::container exited"
docker logs smoke; docker rm -f smoke; exit 1
fi
sleep 2
done
echo "::error title=Smoke test failed::/health/live never answered"
docker logs smoke; docker rm -f smoke; exit 1
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v7
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
image:
name: Publish image → ghcr.io
needs: image-build
runs-on: ubuntu-26.04
environment: release
timeout-minutes: 15
permissions:
contents: read
packages: write # push to ghcr.io
id-token: write # OIDC identity for the provenance attestation
# Writing the attestation itself needs its own scope. Without it the
# publish succeeds and the attest step fails with "Failed to persist
# attestation: Resource not accessible by integration", which fails the
# whole job AFTER the image is already live — v0.1.0 published that way.
attestations: write
steps:
- name: Download digests
uses: actions/download-artifact@v7
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
# From tag v1.2.3 this produces :1.2.3, :1.2, :1 and :latest. The
# floating tags let a self-hoster track patches without editing compose
# files; :1.2.3 is the one to pin in production.
- name: Derive image tags
id: meta
uses: docker/metadata-action@v6
with:
images: ghcr.io/arc-labs-ai/brain-edge
# `latest` is not listed: flavor defaults to latest=auto, which adds it
# for any non-prerelease semver tag. Listing it as well is what gave
# brain-db a duplicate `latest` in its tag set.
#
# The bare major tag is guarded. metadata-action emits `0` for a 0.x
# release, and under semver every 0.x MINOR may break — so a consumer
# pinned to `:0` would be carried from 0.1.0 straight through 0.2.0's
# breaking changes. The guard turns it on by itself at 1.0, where a
# floating major is the point.
tags: |
type=semver,pattern={{version}},value=${{ env.RELEASE_TAG }}
type=semver,pattern={{major}}.{{minor}},value=${{ env.RELEASE_TAG }}
type=semver,pattern={{major}},value=${{ env.RELEASE_TAG }},enable=${{ !startsWith(env.RELEASE_TAG, 'v0.') }}
- uses: docker/setup-buildx-action@v4
- name: Log in to ghcr.io
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# The image name is substituted at the workflow-expression level, leaving
# exactly one `%s` for printf to cycle across each digest filename that
# `*` glob-expands. Passing it as a second printf argument instead would
# add a second %s and misalign every pair after the first digest.
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/arc-labs-ai/brain-edge@sha256:%s ' *)
env:
DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta.outputs.json }}
# Attestation must name the MANIFEST LIST digest, not a per-platform one:
# that is what a consumer pulls and what `${IMAGE}@${DIGEST}` pins.
- name: Resolve the manifest-list digest
id: inspect
run: |
digest="$(docker buildx imagetools inspect \
"ghcr.io/arc-labs-ai/brain-edge:${{ steps.meta.outputs.version }}" \
--format '{{json .Manifest.Digest}}' | tr -d '"')"
echo "digest=${digest}" >> "$GITHUB_OUTPUT"
# Signed provenance linking the image digest back to this workflow run,
# so a consumer can verify where the image came from.
- name: Attest build provenance
uses: actions/attest-build-provenance@v4
with:
subject-name: ghcr.io/arc-labs-ai/brain-edge
subject-digest: ${{ steps.inspect.outputs.digest }}
push-to-registry: true