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 :
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
0 commit comments