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
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*"
# No repo-wide write; each job opts into exactly the scope it needs.
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
jobs:
crates-io:
name: Publish → crates.io
runs-on: ubuntu-latest
environment: release
permissions:
contents: read
id-token: write # OIDC → crates.io trusted publishing
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: brain-edge
- name: Verify tag matches crate version
run: |
TAG="${GITHUB_REF_NAME#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 }}
image:
name: Publish image → ghcr.io
runs-on: ubuntu-latest
environment: release
permissions:
contents: read
packages: write # push to ghcr.io
id-token: write # build provenance attestation
steps:
- uses: actions/checkout@v7
- name: Verify tag matches crate version
run: |
TAG="${GITHUB_REF_NAME#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; }
- 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 }}
# 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}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}},enable=${{ !startsWith(github.ref, 'refs/tags/v0.') }}
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
# Context is the PARENT of both crates, not brain-edge — see the header
# comment in the Dockerfile. Same shape the CI build job uses.
- name: Build and push
id: push
uses: docker/build-push-action@v7
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
# 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.push.outputs.digest }}
push-to-registry: true