Skip to content

Commit aeb4041

Browse files
committed
Publish an image for every service Compose can build, on both architectures
`ghcr.io/copilotkit/openbot` was the only image a release produced, so anything running the Compose stack built `agent-computer`, the supervisor, both Bots and the migration image from source on every machine: a toolchain, and several minutes of Chromium. Each is now published beside it at `ghcr.io/copilotkit/openbot-<service>`, carrying `linux/amd64` and `linux/arm64` so one reference works on a server and on an arm64 laptop. Built on native runners of each architecture and joined into a manifest list, rather than under QEMU, because emulated `bun install` and `vite build` are a known source of release-day flakiness and arm64 runners cost a public repository nothing. Each list gets its own build provenance attestation, and `container-images.json` pins a digest for all of them. Nothing changes for a checkout: unset, `COMPUTER_IMAGE`, `SUPERVISOR_IMAGE`, `BOT_IMAGE`, `LANGGRAPH_IMAGE` and `SERVER_IMAGE` name local tags and Compose builds them as before. Point them at published digests and set `IMAGE_PULL_POLICY=missing` to pull instead. That is deliberately not a promise that nothing is built, and the comment says so: a failed pull falls back to building, which suits a developer and not a machine without a toolchain. `.github/published-images.json` is the list, read by the publishing matrix and by CI, which now builds those five Dockerfiles and fails if the list stops matching the tree. Nothing built them before, because they are built by `docker compose up --build`, which only the smoke journey runs and which cannot run in CI, so a broken one surfaced during a release after the first image had already been pushed.
1 parent cecf146 commit aeb4041

7 files changed

Lines changed: 339 additions & 7 deletions

File tree

‎.github/published-images.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
["agent-computer", "supervisor", "agent-bot", "agent-langgraph", "server"]

‎.github/workflows/ci.yml‎

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,13 +358,63 @@ jobs:
358358
- if: always()
359359
run: docker rm -f openbot-ci >/dev/null 2>&1 || true
360360

361+
# The five images the release publishes alongside the one above. Nothing else here builds them:
362+
# they are built by `docker compose up --build`, which only the smoke journey runs and which
363+
# cannot run in CI. So a broken `supervisor/Dockerfile` used to surface during a release, after
364+
# the `openbot` image had already been pushed, which is the one failure this repository calls out
365+
# as leaving a published image with nothing pointing at it.
366+
#
367+
# Built, not pushed, and one architecture: this answers whether the Dockerfile resolves. Whether
368+
# it resolves on arm64 too is answered at release time, on an arm64 runner.
369+
#
370+
# No registry cache, so a release builds these a second time. `type=gha` needs ACTIONS_* in the
371+
# environment, which `build-push-action` injects and a plain `run:` step does not have, and
372+
# reaching for another action to export them would buy minutes in a job that runs rarely at the
373+
# price of a dependency in the one workflow whose inputs are a security boundary.
374+
component-dockerfiles:
375+
name: component dockerfiles
376+
runs-on: ubuntu-latest
377+
# The same cost gate as `image` above, and for the same reason: `agent-computer` pulls
378+
# Playwright's base and `server` builds the app, so this is minutes rather than seconds. On main
379+
# and through the release's workflow_call it always runs.
380+
if: >-
381+
github.event_name != 'pull_request' ||
382+
contains(github.event.pull_request.labels.*.name, 'full-ci')
383+
steps:
384+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
385+
with:
386+
persist-credentials: false
387+
- uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
388+
# A Dockerfile the release does not publish is one a machine would build from source, so the
389+
# list and the tree have to agree. Checked here rather than trusted, because the failure is
390+
# silent: the release succeeds and a laptop somewhere compiles the app.
391+
- name: Every buildable service is published
392+
run: |
393+
set -euo pipefail
394+
found="$(find . -mindepth 2 -maxdepth 2 -name Dockerfile -not -path './node_modules/*' \
395+
| sed -e 's|^\./||' -e 's|/Dockerfile$||' | sort)"
396+
published="$(jq -r '.[]' .github/published-images.json | sort)"
397+
if [ "$found" != "$published" ]; then
398+
echo "::error::.github/published-images.json does not match the Dockerfiles in the tree."
399+
diff <(echo "$published") <(echo "$found") || true
400+
exit 1
401+
fi
402+
- name: They build
403+
run: |
404+
set -euo pipefail
405+
while read -r image; do
406+
echo "::group::$image"
407+
docker buildx build --file "$image/Dockerfile" --platform linux/amd64 .
408+
echo "::endgroup::"
409+
done < <(jq -r '.[]' .github/published-images.json)
410+
361411
# One check for branch protection to require. A new job above is covered by this without anybody
362412
# remembering to add it to a list, and a job that was skipped for the wrong reason is not a pass.
363413
verify:
364414
name: verify
365415
runs-on: ubuntu-latest
366416
if: always()
367-
needs: [static, deployables, chart, test, build, migrations, image]
417+
needs: [static, deployables, chart, test, build, migrations, image, component-dockerfiles]
368418
steps:
369419
- name: Require every check
370420
env:

‎.github/workflows/publish-release.yml‎

Lines changed: 188 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ jobs:
7777
needs: metadata
7878
if: needs.metadata.outputs.is_release == 'true'
7979
runs-on: ubuntu-latest
80+
outputs:
81+
components: ${{ steps.components.outputs.components }}
8082
steps:
8183
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
8284
with:
@@ -94,6 +96,11 @@ jobs:
9496
echo "::error::CHANGELOG.md has no section for ${VERSION#v}."
9597
exit 1
9698
}
99+
# The services published alongside the image above. Read from the tree rather than written
100+
# here, because CI checks the same file when it proves those Dockerfiles still build, so the
101+
# set that is tested and the set that is published cannot drift apart.
102+
- id: components
103+
run: echo "components=$(jq -c . .github/published-images.json)" >> "$GITHUB_OUTPUT"
97104

98105
# The same checks CI runs, against the commit being published. This is the gate: nothing is built
99106
# or tagged unless they pass here, on this exact tree.
@@ -156,12 +163,167 @@ jobs:
156163
subject-digest: ${{ steps.push.outputs.digest }}
157164
push-to-registry: true
158165

166+
# The images the installer pulls instead of building. `docker-compose.yml` builds these from
167+
# source on every machine, which needs a toolchain and several minutes a desktop install does not
168+
# have. Published here, from the same commit as the image above, so a deployment and a laptop run
169+
# the same code.
170+
#
171+
# Two architectures, because the machines are laptops: arm64 Macs and amd64 everything else. Built
172+
# on native runners rather than under QEMU. Emulated `bun install` and `vite build` are a known
173+
# source of release-day flakiness, and arm64 runners are free to a public repository, so emulation
174+
# would be the slower and less reliable option at no saving.
175+
component-images:
176+
name: ${{ matrix.image }} ${{ matrix.platform.arch }}
177+
needs: [metadata, verify, checks]
178+
if: needs.metadata.outputs.is_release == 'true'
179+
runs-on: ${{ matrix.platform.runner }}
180+
permissions:
181+
contents: read
182+
packages: write
183+
strategy:
184+
# One image failing should not hide whether the others build, and a half-finished run leaves
185+
# nothing deployable: these builds are pushed untagged, and the tags are written by the job
186+
# below only once both architectures of an image exist.
187+
fail-fast: false
188+
matrix:
189+
image: ${{ fromJSON(needs.verify.outputs.components) }}
190+
platform:
191+
- arch: amd64
192+
runner: ubuntu-latest
193+
- arch: arm64
194+
runner: ubuntu-24.04-arm
195+
steps:
196+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
197+
with:
198+
ref: ${{ github.sha }}
199+
persist-credentials: false
200+
- uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
201+
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
202+
with:
203+
registry: ghcr.io
204+
username: ${{ github.actor }}
205+
password: ${{ secrets.GITHUB_TOKEN }}
206+
# Pushed by digest and deliberately untagged. A tag written here would name one architecture,
207+
# and the two jobs for one image would race to own it, so the last to finish would decide what
208+
# the tag meant.
209+
- id: push
210+
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
211+
with:
212+
context: .
213+
file: ${{ matrix.image }}/Dockerfile
214+
platforms: linux/${{ matrix.platform.arch }}
215+
outputs: type=image,name=ghcr.io/copilotkit/openbot-${{ matrix.image }},push-by-digest=true,name-canonical=true,push=true
216+
# Scoped per image and per architecture. One shared scope would have ten builds
217+
# overwriting each other's cache and none of them reading their own.
218+
cache-from: type=gha,scope=${{ matrix.image }}-${{ matrix.platform.arch }}
219+
cache-to: type=gha,mode=max,scope=${{ matrix.image }}-${{ matrix.platform.arch }}
220+
provenance: true
221+
sbom: true
222+
# A matrix job's outputs are not addressable by the jobs that consume them, so the digest
223+
# travels as a file. One artifact per image and architecture, because same-named artifacts
224+
# from different matrix legs collide.
225+
- name: Record the digest
226+
env:
227+
DIGEST: ${{ steps.push.outputs.digest }}
228+
ARCH: ${{ matrix.platform.arch }}
229+
run: |
230+
set -euo pipefail
231+
[[ "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]
232+
mkdir -p digests
233+
echo "$DIGEST" > "digests/$ARCH"
234+
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
235+
with:
236+
name: digest-${{ matrix.image }}-${{ matrix.platform.arch }}
237+
path: digests/
238+
retention-days: 1
239+
240+
# Two per-architecture images become one reference. Whatever pulls it, a laptop or a cluster, names
241+
# the manifest list and gets its own architecture without being told which one it is.
242+
component-manifests:
243+
name: ${{ matrix.image }} manifest
244+
needs: [metadata, verify, checks, component-images]
245+
if: needs.metadata.outputs.is_release == 'true'
246+
runs-on: ubuntu-latest
247+
permissions:
248+
contents: read
249+
packages: write
250+
# Same identity and the same reason as the image above: the attestation says which workflow,
251+
# repository and commit produced this, and there is no key to hold.
252+
id-token: write
253+
attestations: write
254+
strategy:
255+
fail-fast: false
256+
matrix:
257+
image: ${{ fromJSON(needs.verify.outputs.components) }}
258+
steps:
259+
- uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
260+
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
261+
with:
262+
registry: ghcr.io
263+
username: ${{ github.actor }}
264+
password: ${{ secrets.GITHUB_TOKEN }}
265+
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
266+
with:
267+
pattern: digest-${{ matrix.image }}-*
268+
path: digests
269+
merge-multiple: true
270+
- id: merge
271+
name: Write the tags over both architectures
272+
env:
273+
REPOSITORY: ghcr.io/copilotkit/openbot-${{ matrix.image }}
274+
VERSION: ${{ needs.metadata.outputs.version }}
275+
COMMIT: ${{ github.sha }}
276+
run: |
277+
set -euo pipefail
278+
# Both architectures or neither. A manifest list holding one of them installs on half the
279+
# machines and looks exactly like one holding both until somebody's laptop says
280+
# "no matching manifest".
281+
refs=()
282+
for arch in amd64 arm64; do
283+
digest="$(cat "digests/$arch")"
284+
[[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]
285+
refs+=("$REPOSITORY@$digest")
286+
done
287+
docker buildx imagetools create \
288+
--tag "$REPOSITORY:$VERSION" \
289+
--tag "$REPOSITORY:$COMMIT" \
290+
--tag "$REPOSITORY:latest" \
291+
"${refs[@]}"
292+
# The list's own digest, which is what anything downstream pins. Read back from the
293+
# registry rather than derived here, and checked, so a template that stops returning a
294+
# digest fails now instead of writing something unusable into the release.
295+
digest="$(docker buildx imagetools inspect "$REPOSITORY:$VERSION" --format '{{.Manifest.Digest}}')"
296+
[[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]
297+
echo "digest=$digest" >> "$GITHUB_OUTPUT"
298+
- uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
299+
with:
300+
subject-name: ghcr.io/copilotkit/openbot-${{ matrix.image }}
301+
subject-digest: ${{ steps.merge.outputs.digest }}
302+
push-to-registry: true
303+
# For the same reason the digests above travel as files: a matrix cannot hand a value to a
304+
# later job.
305+
- name: Record the manifest
306+
env:
307+
NAME: ${{ matrix.image }}
308+
REPOSITORY: ghcr.io/copilotkit/openbot-${{ matrix.image }}
309+
DIGEST: ${{ steps.merge.outputs.digest }}
310+
run: |
311+
set -euo pipefail
312+
mkdir -p manifests
313+
jq -n --arg name "$NAME" --arg repository "$REPOSITORY" --arg digest "$DIGEST" \
314+
'{name: $name, repository: $repository, digest: $digest}' > "manifests/$NAME.json"
315+
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
316+
with:
317+
name: manifest-${{ matrix.image }}
318+
path: manifests/
319+
retention-days: 1
320+
159321
# The tag and the release, last, so nothing is announced that was not built. The manifest is the
160322
# useful artefact: it pins the digest, so a deploy or a rollback names an exact image rather than a
161323
# tag somebody could move.
162324
github-release:
163325
name: tag and release
164-
needs: [metadata, verify, checks, image]
326+
needs: [metadata, verify, checks, image, component-manifests]
165327
if: needs.metadata.outputs.is_release == 'true'
166328
runs-on: ubuntu-latest
167329
permissions:
@@ -173,14 +335,36 @@ jobs:
173335
with:
174336
ref: ${{ github.sha }}
175337
persist-credentials: false
338+
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
339+
with:
340+
pattern: manifest-*
341+
path: manifests
342+
merge-multiple: true
176343
- name: Write the image manifest
177344
env:
178345
VERSION: ${{ needs.metadata.outputs.version }}
179346
DIGEST: ${{ needs.image.outputs.digest }}
180347
COMMIT: ${{ github.sha }}
348+
COMPONENTS: ${{ needs.verify.outputs.components }}
181349
run: |
182350
set -euo pipefail
183351
[[ "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]
352+
# Every image this release published, or the file is not written. A missing entry is a
353+
# service whatever reads this would quietly build from source instead, which is the thing
354+
# publishing them was for, and it would only be noticed on somebody's laptop.
355+
expected="$(jq -r 'length' <<< "$COMPONENTS")"
356+
found="$(find manifests -name '*.json' -type f | wc -l | tr -d ' ')"
357+
if [ "$found" != "$expected" ]; then
358+
echo "::error::Expected $expected component manifests, found $found."
359+
exit 1
360+
fi
361+
components="$(jq -s 'map({
362+
(.name): {
363+
repository: .repository,
364+
digest: .digest,
365+
reference: (.repository + "@" + .digest),
366+
}
367+
}) | add' manifests/*.json)"
184368
# `jq`, not `bun`: this job deliberately checks out without credentials and installs no
185369
# toolchain, so reaching for the repository's runtime here is a step that was never taken.
186370
# It was, and the tag was never cut: the manifest step died on `bun: command not found`
@@ -191,16 +375,17 @@ jobs:
191375
--arg digest "$DIGEST" \
192376
--arg commit "$COMMIT" \
193377
--arg repository "ghcr.io/copilotkit/openbot" \
378+
--argjson components "$components" \
194379
'{
195380
version: $version,
196381
commit: $commit,
197-
images: {
382+
images: ({
198383
openbot: {
199384
repository: $repository,
200385
digest: $digest,
201386
reference: ($repository + "@" + $digest),
202387
},
203-
},
388+
} + $components),
204389
}' > container-images.json
205390
cat container-images.json
206391
- name: Tag and publish

‎CHANGELOG.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,27 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
88

99
## Unreleased
1010

11+
### A release publishes every service's image, not just the one
12+
13+
`ghcr.io/copilotkit/openbot` was the only image a release produced, so anything running the Compose
14+
stack built `agent-computer`, the supervisor, both Bots and the migration image from source on
15+
every machine. That needs a toolchain and it needs several minutes, most of them Chromium, and it
16+
is the difference between a deployment and a laptop being able to start at all.
17+
18+
Each of those is now published beside it, at `ghcr.io/copilotkit/openbot-<service>`, carrying both
19+
`linux/amd64` and `linux/arm64` so one reference works on a server and on an arm64 laptop. Every
20+
image gets its own build provenance attestation, and `container-images.json` on the release pins a
21+
digest for all of them rather than for one.
22+
23+
Nothing changes for a checkout: unset, `COMPUTER_IMAGE`, `SUPERVISOR_IMAGE`, `BOT_IMAGE`,
24+
`LANGGRAPH_IMAGE` and `SERVER_IMAGE` name local tags and Compose builds them as before. Point them
25+
at published digests and set `IMAGE_PULL_POLICY=missing` to pull instead. `docs/releasing.md` shows
26+
reading the references out of `container-images.json`; `docs/configuration.md` lists the settings.
27+
28+
CI now builds those five Dockerfiles too. Nothing did before, because they are built by
29+
`docker compose up --build`, which only the smoke journey runs and which cannot run in CI, so a
30+
broken one surfaced during a release after the first image had already been pushed.
31+
1132
### The trail says who a coworker was opened to
1233

1334
Making a coworker public admits every signed-in person to it, and being admitted to a coworker is

‎docker-compose.yml‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,18 @@ services:
3939
build:
4040
context: .
4141
dockerfile: server/Dockerfile
42+
# Named, and pullable, for the same reason as agent-computer below: a release publishes this
43+
# image, and a machine that can name it does not need a toolchain to build it. `pull_policy`
44+
# is what decides between the two, because a service carrying a `build` section builds by
45+
# default however its image is named. IMAGE_PULL_POLICY=missing makes it pull instead.
46+
#
47+
# Not a guarantee that nothing is built: a pull that fails falls back to building, silently,
48+
# which is right for a developer and wrong for an installed OpenBot, where there is no
49+
# toolchain and the honest answer is that the image could not be fetched. Anything shipping
50+
# these images to a machine that cannot build them overrides the `build` section away rather
51+
# than relying on this variable.
52+
image: ${SERVER_IMAGE:-openbot-server:latest}
53+
pull_policy: ${IMAGE_PULL_POLICY:-build}
4254
command: ["bun", "x", "drizzle-kit", "migrate", "--config=drizzle.config.ts"]
4355
environment:
4456
DATABASE_URL: postgres://openbot:openbot@postgres:5432/openbot
@@ -57,6 +69,7 @@ services:
5769
# Tagged explicitly because the supervisor starts one computer per Bot from this image by name,
5870
# and a Compose-derived name would change with the project.
5971
image: ${COMPUTER_IMAGE:-openbot-agent-computer:latest}
72+
pull_policy: ${IMAGE_PULL_POLICY:-build}
6073
ports:
6174
# Loopback only. This process drives a browser holding real logins; COMPUTER_TOKEN is the
6275
# request control, and loopback keeps the surface off routed networks.
@@ -158,6 +171,9 @@ services:
158171
build:
159172
context: .
160173
dockerfile: supervisor/Dockerfile
174+
# Published by a release; see migrate above for what pull_policy decides.
175+
image: ${SUPERVISOR_IMAGE:-openbot-supervisor:latest}
176+
pull_policy: ${IMAGE_PULL_POLICY:-build}
161177
# The same file, because this process does not read these itself: it forwards every EGRESS_PROXY
162178
# key out of its own environment into each computer it creates, so it has to be given them first.
163179
env_file:
@@ -218,6 +234,9 @@ services:
218234
build:
219235
context: .
220236
dockerfile: agent-bot/Dockerfile
237+
# Published by a release; see migrate above for what pull_policy decides.
238+
image: ${BOT_IMAGE:-openbot-agent-bot:latest}
239+
pull_policy: ${IMAGE_PULL_POLICY:-build}
221240
ports:
222241
# Loopback, not every interface. The token is the boundary; this means an attacker needs to be
223242
# on the machine before they can even try it. Nothing legitimate reaches a Bot from another
@@ -245,6 +264,9 @@ services:
245264
build:
246265
context: .
247266
dockerfile: agent-langgraph/Dockerfile
267+
# Published by a release; see migrate above for what pull_policy decides.
268+
image: ${LANGGRAPH_IMAGE:-openbot-agent-langgraph:latest}
269+
pull_policy: ${IMAGE_PULL_POLICY:-build}
248270
ports:
249271
# Loopback, for the same reason as agent-bot above.
250272
- "127.0.0.1:${LANGGRAPH_PORT:-4201}:4201"

0 commit comments

Comments
 (0)