11name : Docker Publish
22
3+ # -----------------------------------------------------------------------------
4+ # Performance optimization: Native multi-platform builds
5+ # -----------------------------------------------------------------------------
6+ # Previously, this workflow built both amd64 and arm64 in a single job using
7+ # QEMU emulation. arm64 compilation under QEMU was extremely slow:
8+ # - Rust broker compile: 15.7 min (vs ~30s native)
9+ # - bun install + UI build: 9.3 min (vs ~54s native)
10+ # - Registry cache export: 6.8 min
11+ # - Total: ~37 min for the "Build and push" step alone
12+ #
13+ # Now we split into parallel native builds:
14+ # - amd64 builds natively on ubuntu-latest
15+ # - arm64 builds natively on ubuntu-24.04-arm (GitHub-hosted ARM runner)
16+ # - A merge job assembles the multi-arch manifest
17+ # This reduces wall-clock time from ~43 min to ~11-13 min.
18+ # -----------------------------------------------------------------------------
19+
320on :
421 push :
522 tags :
1936 outputs :
2037 scout_policy_status :
2138 description : ' Docker Scout policy status (pass, fail, or error)'
22- value : ${{ jobs.build -and-push .outputs.scout_policy_status }}
39+ value : ${{ jobs.merge -and-publish .outputs.scout_policy_status }}
2340 scout_policy_exit_code :
2441 description : ' Exit code returned by docker scout policy'
25- value : ${{ jobs.build -and-push .outputs.scout_policy_exit_code }}
42+ value : ${{ jobs.merge -and-publish .outputs.scout_policy_exit_code }}
2643
2744concurrency :
2845 group : ${{ github.workflow }}-${{ github.ref }}
3350 IMAGE_NAME_DOCKERHUB : ${{ vars.DOCKERHUB_USERNAME }}/opencode-cloud-sandbox
3451
3552jobs :
36- build-and-push :
37- name : Build and Push Sandbox Docker Image
53+ # ---------------------------------------------------------------------------
54+ # Job 1: Build & test amd64 image (gate for all subsequent jobs)
55+ # ---------------------------------------------------------------------------
56+ build-test :
57+ name : Build Test Image (amd64)
3858 runs-on : ubuntu-latest
39- timeout-minutes : 90
59+ timeout-minutes : 30
4060 outputs :
41- scout_policy_status : ${{ steps.scout-policy .outputs.status }}
42- scout_policy_exit_code : ${{ steps.scout-policy .outputs.exit_code }}
61+ version : ${{ steps.version .outputs.version }}
62+ oci_description : ${{ steps.oci-description .outputs.description }}
4363 permissions :
4464 contents : read
45- packages : write # Required for GHCR push
65+ packages : read
4666
4767 steps :
4868 - name : Disk + Docker usage
95115 echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
96116 echo "Building version: ${VERSION}"
97117
98- - name : Set up QEMU
99- uses : docker/setup-qemu-action@v3
100-
101118 - name : Set up Docker Buildx
102119 uses : docker/setup-buildx-action@v3
103120
@@ -106,20 +123,6 @@ jobs:
106123 # The Dockerfile is embedded in the Rust source
107124 cp packages/core/src/docker/Dockerfile .
108125
109- - name : Docker metadata
110- id : meta
111- uses : docker/metadata-action@v5
112- with :
113- images : |
114- ${{ env.IMAGE_NAME_GHCR }}
115- ${{ env.IMAGE_NAME_DOCKERHUB }}
116- tags : |
117- type=semver,pattern={{version}},value=${{ steps.version.outputs.version }}
118- type=raw,value=latest
119-
120- # Build and test BEFORE authenticating to registries
121- # If smoke test fails, we don't waste time on login steps
122-
123126 - name : Build test image (amd64 only)
124127 uses : docker/build-push-action@v6
125128 env :
@@ -134,12 +137,12 @@ jobs:
134137 tags : |
135138 ${{ env.IMAGE_NAME_GHCR }}:test
136139 # Separate GHA scope to avoid overwriting the main publish build cache.
137- # Also pull from the publish scope and registry cache for cross-build reuse
140+ # Also pull from the amd64 publish scope and registry cache for cross-build reuse
138141 # (e.g. reuse base/broker-deps layers from the last publish build).
139142 cache-from : |
140143 type=gha,scope=opencode-cloud-sandbox-test,version=2
141- type=gha,scope=opencode-cloud-sandbox,version=2
142- type=registry,ref=${{ env.IMAGE_NAME_GHCR }}:buildcache
144+ type=gha,scope=opencode-cloud-sandbox-amd64 ,version=2
145+ type=registry,ref=${{ env.IMAGE_NAME_GHCR }}:buildcache-amd64
143146 cache-to : type=gha,scope=opencode-cloud-sandbox-test,mode=min,version=2
144147
145148 # Temporary disabled smoke test
@@ -198,7 +201,67 @@ jobs:
198201 # docker rm smoke-test || true
199202 # exit 1
200203
201- # Only authenticate after smoke test passes
204+ # ---------------------------------------------------------------------------
205+ # Jobs 2+3: Build each platform natively and push by digest
206+ # ---------------------------------------------------------------------------
207+ # Each platform builds on its native runner (no QEMU emulation), pushes
208+ # a single-platform image by digest (no tag), and uploads the digest as
209+ # an artifact. The merge job assembles the multi-arch manifest from digests.
210+ #
211+ # This "push-by-digest" pattern is documented at:
212+ # https://docs.docker.com/build/ci/github-actions/multi-platform/#distribute-build-across-multiple-runners
213+ build-platform :
214+ name : Build ${{ matrix.platform }}
215+ needs : [build-test]
216+ strategy :
217+ fail-fast : true
218+ matrix :
219+ include :
220+ - platform : linux/amd64
221+ runner : ubuntu-latest
222+ arch : amd64
223+ - platform : linux/arm64
224+ runner : ubuntu-24.04-arm
225+ arch : arm64
226+ runs-on : ${{ matrix.runner }}
227+ timeout-minutes : 45
228+ permissions :
229+ contents : read
230+ packages : write
231+
232+ steps :
233+ - name : Free disk space
234+ run : |
235+ set -euo pipefail
236+ echo "Before:"
237+ df -h
238+ sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc || true
239+ sudo rm -rf /usr/local/share/boost || true
240+ sudo apt-get clean
241+ sudo rm -rf /var/lib/apt/lists/*
242+ sudo rm -rf "$AGENT_TOOLSDIRECTORY" || true
243+ docker system prune -af || true
244+ echo "After:"
245+ df -h
246+
247+ - uses : actions/checkout@v6
248+ with :
249+ ref : ${{ inputs.ref }}
250+
251+ - name : Set up Docker Buildx
252+ uses : docker/setup-buildx-action@v3
253+
254+ - name : Extract Dockerfile from embedded location
255+ run : |
256+ cp packages/core/src/docker/Dockerfile .
257+
258+ - name : Docker metadata
259+ id : meta
260+ uses : docker/metadata-action@v5
261+ with :
262+ images : |
263+ ${{ env.IMAGE_NAME_GHCR }}
264+ ${{ env.IMAGE_NAME_DOCKERHUB }}
202265
203266 - name : Log in to GHCR
204267 uses : docker/login-action@v3
@@ -213,37 +276,140 @@ jobs:
213276 username : ${{ vars.DOCKERHUB_USERNAME }}
214277 password : ${{ secrets.DOCKERHUB_TOKEN }}
215278
216- - name : Build and push
279+ # Push a single-platform image by digest (no tag). The digest is used
280+ # by the merge job to assemble the final multi-arch manifest.
281+ - name : Build and push by digest
282+ id : build
217283 uses : docker/build-push-action@v6
218284 env :
219285 BUILDKIT_PROGRESS : plain
220286 with :
221287 context : .
222- push : true
223- platforms : linux/amd64,linux/arm64
288+ platforms : ${{ matrix.platform }}
224289 build-args : |
225- OPENCODE_CLOUD_VERSION=${{ steps.version.outputs.version }}
226- tags : ${{ steps.meta.outputs.tags }}
290+ OPENCODE_CLOUD_VERSION=${{ needs.build-test.outputs.version }}
227291 labels : ${{ steps.meta.outputs.labels }}
228292 annotations : |
229- org.opencontainers.image.description=${{ steps.oci-description .outputs.description }}
293+ org.opencontainers.image.description=${{ needs.build-test .outputs.oci_description }}
230294 sbom : true
231295 provenance : mode=max
232- # Scoped cache prevents thrash between test and publish builds.
233- # GHA cache v2 improves compatibility with the current cache API.
296+ # push-by-digest pushes the image without tags; the merge job
297+ # attaches the version + latest tags to the multi-arch manifest.
298+ outputs : type=image,"name=${{ env.IMAGE_NAME_GHCR }},${{ env.IMAGE_NAME_DOCKERHUB }}",push-by-digest=true,name-canonical=true,push=true
299+ # Per-architecture cache scopes prevent cross-platform cache thrashing.
300+ # mode=min for registry cache: only cache final-stage layers to avoid
301+ # the costly 6+ minute full-layer export that mode=max incurs.
302+ # GHA cache provides intermediate layer caching within GitHub's infra.
234303 cache-from : |
235- type=registry,ref=${{ env.IMAGE_NAME_GHCR }}:buildcache
236- type=gha,scope=opencode-cloud-sandbox,version=2
304+ type=registry,ref=${{ env.IMAGE_NAME_GHCR }}:buildcache-${{ matrix.arch }}
305+ type=gha,scope=opencode-cloud-sandbox-${{ matrix.arch }} ,version=2
237306 cache-to : |
238- type=registry,ref=${{ env.IMAGE_NAME_GHCR }}:buildcache,mode=max
239- type=gha,scope=opencode-cloud-sandbox,mode=min,version=2
307+ type=registry,ref=${{ env.IMAGE_NAME_GHCR }}:buildcache-${{ matrix.arch }},mode=min
308+ type=gha,scope=opencode-cloud-sandbox-${{ matrix.arch }},mode=min,version=2
309+
310+ # Export the image digest so the merge job can reference it.
311+ - name : Export digest
312+ run : |
313+ set -euo pipefail
314+ mkdir -p /tmp/digests
315+ digest="${{ steps.build.outputs.digest }}"
316+ touch "/tmp/digests/${digest#sha256:}"
317+
318+ - name : Upload digest
319+ uses : actions/upload-artifact@v4
320+ with :
321+ name : digests-${{ matrix.arch }}
322+ path : /tmp/digests/*
323+ if-no-files-found : error
324+ retention-days : 1
325+
326+ # ---------------------------------------------------------------------------
327+ # Job 4: Merge per-platform images into multi-arch manifest + post-publish
328+ # ---------------------------------------------------------------------------
329+ # Downloads digests from both platform builds, creates a multi-arch manifest
330+ # tagged with version + latest on both GHCR and Docker Hub, then runs
331+ # Docker Scout analysis and updates the Docker Hub description.
332+ merge-and-publish :
333+ name : Merge Manifest & Publish
334+ needs : [build-test, build-platform]
335+ runs-on : ubuntu-latest
336+ timeout-minutes : 15
337+ outputs :
338+ scout_policy_status : ${{ steps.scout-policy.outputs.status }}
339+ scout_policy_exit_code : ${{ steps.scout-policy.outputs.exit_code }}
340+ permissions :
341+ contents : read
342+ packages : write
343+
344+ steps :
345+ - uses : actions/checkout@v6
346+ with :
347+ ref : ${{ inputs.ref }}
348+
349+ - name : Download digests
350+ uses : actions/download-artifact@v4
351+ with :
352+ path : /tmp/digests
353+ pattern : digests-*
354+ merge-multiple : true
355+
356+ - name : Log in to GHCR
357+ uses : docker/login-action@v3
358+ with :
359+ registry : ghcr.io
360+ username : ${{ github.actor }}
361+ password : ${{ secrets.GITHUB_TOKEN }}
362+
363+ - name : Log in to Docker Hub
364+ uses : docker/login-action@v3
365+ with :
366+ username : ${{ vars.DOCKERHUB_USERNAME }}
367+ password : ${{ secrets.DOCKERHUB_TOKEN }}
368+
369+ - name : Set up Docker Buildx
370+ uses : docker/setup-buildx-action@v3
371+
372+ - name : Docker metadata
373+ id : meta
374+ uses : docker/metadata-action@v5
375+ with :
376+ images : |
377+ ${{ env.IMAGE_NAME_GHCR }}
378+ ${{ env.IMAGE_NAME_DOCKERHUB }}
379+ tags : |
380+ type=semver,pattern={{version}},value=${{ needs.build-test.outputs.version }}
381+ type=raw,value=latest
382+
383+ # Merge per-platform digests into a single multi-arch manifest and
384+ # tag it on both registries. The annotation ensures GHCR shows the
385+ # image description.
386+ - name : Create multi-arch manifest
387+ working-directory : /tmp/digests
388+ run : |
389+ set -euo pipefail
390+ echo "Digests to merge:"
391+ ls -la
392+
393+ # Build the source image list from digest filenames
394+ SOURCES=$(printf '${{ env.IMAGE_NAME_GHCR }}@sha256:%s ' *)
395+
396+ # Create and push multi-arch manifest to all tags on all registries.
397+ # docker/metadata-action outputs one tag per line; convert to -t flags.
398+ docker buildx imagetools create \
399+ $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< '${{ steps.meta.outputs.json }}') \
400+ --annotation 'index:org.opencontainers.image.description=${{ needs.build-test.outputs.oci_description }}' \
401+ ${SOURCES}
402+
403+ - name : Inspect manifest
404+ run : |
405+ docker buildx imagetools inspect "${{ env.IMAGE_NAME_GHCR }}:${{ needs.build-test.outputs.version }}"
240406
241407 - name : Docker Scout Quickview
242408 continue-on-error : true
243409 uses : docker/scout-action@v1
244410 with :
245411 command : quickview
246- image : ${{ env.IMAGE_NAME_DOCKERHUB }}:${{ steps.version .outputs.version }}
412+ image : ${{ env.IMAGE_NAME_DOCKERHUB }}:${{ needs.build-test .outputs.version }}
247413 summary : true
248414 write-comment : false
249415
@@ -270,7 +436,7 @@ jobs:
270436
271437 set +e
272438 docker scout policy \
273- "${IMAGE_NAME_DOCKERHUB}:${{ steps.version .outputs.version }}" \
439+ "${IMAGE_NAME_DOCKERHUB}:${{ needs.build-test .outputs.version }}" \
274440 --org "${org}" --exit-code
275441 scout_exit_code=$?
276442 set -e
@@ -287,7 +453,7 @@ jobs:
287453 - name : Summarize Docker Scout Policy
288454 if : always()
289455 env :
290- VERSIONED_IMAGE : ${{ env.IMAGE_NAME_DOCKERHUB }}:${{ steps.version .outputs.version }}
456+ VERSIONED_IMAGE : ${{ env.IMAGE_NAME_DOCKERHUB }}:${{ needs.build-test .outputs.version }}
291457 SCOUT_POLICY_STATUS : ${{ steps.scout-policy.outputs.status }}
292458 SCOUT_POLICY_EXIT_CODE : ${{ steps.scout-policy.outputs.exit_code }}
293459 run : |
@@ -314,16 +480,16 @@ jobs:
314480 run : |
315481 echo "## 🐳 Sandbox Docker Images Published" >> $GITHUB_STEP_SUMMARY
316482 echo "" >> $GITHUB_STEP_SUMMARY
317- echo "**Version:** ${{ steps.version .outputs.version }}" >> $GITHUB_STEP_SUMMARY
483+ echo "**Version:** ${{ needs.build-test .outputs.version }}" >> $GITHUB_STEP_SUMMARY
318484 echo "" >> $GITHUB_STEP_SUMMARY
319485 echo "**GHCR (GitHub Container Registry):**" >> $GITHUB_STEP_SUMMARY
320486 echo "- https://github.com/pRizz/opencode-cloud/pkgs/container/opencode-cloud-sandbox" >> $GITHUB_STEP_SUMMARY
321- echo "- \`${{ env.IMAGE_NAME_GHCR }}:${{ steps.version .outputs.version }}\`" >> $GITHUB_STEP_SUMMARY
487+ echo "- \`${{ env.IMAGE_NAME_GHCR }}:${{ needs.build-test .outputs.version }}\`" >> $GITHUB_STEP_SUMMARY
322488 echo "- \`${{ env.IMAGE_NAME_GHCR }}:latest\`" >> $GITHUB_STEP_SUMMARY
323489 echo "" >> $GITHUB_STEP_SUMMARY
324490 echo "**Docker Hub:**" >> $GITHUB_STEP_SUMMARY
325491 echo "- https://hub.docker.com/r/${{ vars.DOCKERHUB_USERNAME }}/opencode-cloud-sandbox" >> $GITHUB_STEP_SUMMARY
326- echo "- \`${{ env.IMAGE_NAME_DOCKERHUB }}:${{ steps.version .outputs.version }}\`" >> $GITHUB_STEP_SUMMARY
492+ echo "- \`${{ env.IMAGE_NAME_DOCKERHUB }}:${{ needs.build-test .outputs.version }}\`" >> $GITHUB_STEP_SUMMARY
327493 echo "- \`${{ env.IMAGE_NAME_DOCKERHUB }}:latest\`" >> $GITHUB_STEP_SUMMARY
328494 echo "" >> $GITHUB_STEP_SUMMARY
329495 echo "**Platforms:** linux/amd64, linux/arm64" >> $GITHUB_STEP_SUMMARY
0 commit comments