ci(release): split docker onto native arm runners + reuse prebuilt bundles (#922)

This commit is contained in:
Carlos Florêncio
2026-05-17 20:15:31 +01:00
committed by GitHub
parent e346572371
commit ca1e72374b
2 changed files with 342 additions and 170 deletions
+297 -82
View File
@@ -4,14 +4,21 @@ name: release
# Trigger: GitHub Actions UI → "Run workflow" (workflow_dispatch).
#
# Flow (one click → everything below):
# 1. prepare — bump version, regen CHANGELOG, open + merge release PR, push tag vX.Y.Z
# 2. build-web — build Next.js standalone bundle
# 3. build-cli — build + esbuild-bundle CLI
# 4. build-bundles — assemble bin/ + web/ + cli/ tarballs for 5 platforms
# 5. docker — build & push multi-arch container image to GHCR
# 6. publish-release — create GH release with .tar.gz + .sha256 assets + auto-generated notes
# 7. publish-npm — publish kandev@X.Y.Z + @kdlbs/runtime-*@X.Y.Z to npmjs
# 8. update-homebrew-tap — push updated Formula/kandev.rb to kdlbs/homebrew-kandev
# 1. prepare — bump version, regen CHANGELOG, open + merge release PR, push tag vX.Y.Z
# 2. build-web — build Next.js standalone bundle (shared across arches)
# 3. build-cli — build + esbuild-bundle CLI (shared across arches)
# 4. build-bundles — assemble bin/ + web/ + cli/ tarballs for 5 platforms
# 5. docker-{amd64,arm64} — native per-arch image builds (no QEMU), consuming
# the prebuilt linux bundles from step 4
# 6. docker-manifest — stitch the two arch digests into the user-facing
# multi-arch tags via `buildx imagetools create`
# 7. docker-universal-{amd64,arm64} — native universal builds on top of
# docker-manifest's per-arch base
# 8. docker-universal-manifest — size-gate amd64 staging, then promote both
# arches to the user-facing :universal tags
# 9. publish-release — create GH release with .tar.gz + .sha256 assets + auto-generated notes
# 10. publish-npm — publish kandev@X.Y.Z + @kdlbs/runtime-*@X.Y.Z to npmjs
# 11. update-homebrew-tap — push updated Formula/kandev.rb to kdlbs/homebrew-kandev
#
# npm publishing uses Trusted Publishers (OIDC) — no NPM_TOKEN required.
# Each package (kandev + 5 @kdlbs/runtime-* packages) must have this workflow
@@ -255,6 +262,8 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: pnpm
cache-dependency-path: apps/pnpm-lock.yaml
- name: Install dependencies
run: pnpm -C apps install --frozen-lockfile
- name: Build web (standalone)
@@ -284,6 +293,8 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: pnpm
cache-dependency-path: apps/pnpm-lock.yaml
- name: Install dependencies
run: pnpm -C apps install --frozen-lockfile
- name: Build and bundle CLI
@@ -304,11 +315,10 @@ jobs:
platform: linux-x64
goos: linux
goarch: amd64
- os: ubuntu-latest
- os: ubuntu-24.04-arm
platform: linux-arm64
goos: linux
goarch: arm64
cc: aarch64-linux-gnu-gcc
- os: macos-14
platform: macos-x64
goos: darwin
@@ -348,7 +358,7 @@ jobs:
if: matrix.cc
run: |
sudo apt-get update
sudo apt-get install -y ${{ matrix.cc == 'aarch64-linux-gnu-gcc' && 'gcc-aarch64-linux-gnu' || 'gcc-mingw-w64-x86-64' }}
sudo apt-get install -y gcc-mingw-w64-x86-64
- name: Build backend binaries
env:
@@ -398,19 +408,151 @@ jobs:
dist/kandev-${{ matrix.platform }}.tar.gz
dist/kandev-${{ matrix.platform }}.tar.gz.sha256
docker:
name: Build & push container image
needs: prepare
# ---------------------------------------------------------------------------
# Docker base image — split per arch onto native runners, then stitched into
# a multi-arch manifest list. Each arch consumes the matching linux bundle
# produced by build-bundles, so no Go/Next.js/pnpm work happens inside the
# Docker build (the Dockerfile is reduced to apt-get + COPY).
# ---------------------------------------------------------------------------
docker-amd64:
name: Build & push container image (amd64)
needs: [prepare, build-bundles]
if: ${{ !inputs.dry_run }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # push to GHCR
packages: write
outputs:
digest: ${{ steps.build.outputs.digest }}
env:
STAGING_IMAGE: ghcr.io/kdlbs/kandev:base-staging-${{ needs.prepare.outputs.tag }}-${{ github.run_id }}-amd64
steps:
- uses: actions/checkout@v6
with:
ref: ${{ needs.prepare.outputs.tag }}
- uses: actions/download-artifact@v4
with:
name: bundle-linux-x64
path: dist/bundle
- name: Prepare Docker build context
run: |
set -euo pipefail
mkdir -p ctx/bundle
# --strip-components=1 drops the top-level `kandev/` dir inside the
# tarball so ctx/bundle/ ends up with bin/, web/, cli/ directly.
tar -xzf dist/bundle/kandev-linux-x64.tar.gz --strip-components=1 -C ctx/bundle
cp docker-entrypoint.sh ctx/
- uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# OCI labels are produced once here per arch. The manifest job combines
# the per-arch image manifests into a multi-arch list via `imagetools
# create`, which cannot retroactively add labels to image configs, so
# the labels MUST be stamped at build time on each arch. GHCR's package
# page link + provenance tooling rely on these annotations.
- name: Docker metadata (labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/kdlbs/kandev
- name: Build and push (amd64 staging tag)
id: build
uses: docker/build-push-action@v6
with:
context: ./ctx
file: ./Dockerfile
push: true
platforms: linux/amd64
tags: ${{ env.STAGING_IMAGE }}
labels: ${{ steps.meta.outputs.labels }}
# provenance/sbom would attach an extra attestation manifest, which
# turns `imagetools inspect --raw` into an OCI index and breaks the
# size gate's `.layers[].size` query. The final multi-arch tags
# don't preserve attestations through `imagetools create` anyway,
# so disabling here doesn't change user-facing image content.
provenance: false
sbom: false
cache-from: type=gha,scope=base-amd64
cache-to: type=gha,mode=max,scope=base-amd64
docker-arm64:
name: Build & push container image (arm64)
needs: [prepare, build-bundles]
if: ${{ !inputs.dry_run }}
runs-on: ubuntu-24.04-arm
permissions:
contents: read
packages: write
outputs:
digest: ${{ steps.build.outputs.digest }}
env:
STAGING_IMAGE: ghcr.io/kdlbs/kandev:base-staging-${{ needs.prepare.outputs.tag }}-${{ github.run_id }}-arm64
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.prepare.outputs.tag }}
- uses: actions/download-artifact@v4
with:
name: bundle-linux-arm64
path: dist/bundle
- name: Prepare Docker build context
run: |
set -euo pipefail
mkdir -p ctx/bundle
tar -xzf dist/bundle/kandev-linux-arm64.tar.gz --strip-components=1 -C ctx/bundle
cp docker-entrypoint.sh ctx/
- uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker metadata (labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/kdlbs/kandev
- name: Build and push (arm64 staging tag)
id: build
uses: docker/build-push-action@v6
with:
context: ./ctx
file: ./Dockerfile
push: true
platforms: linux/arm64
tags: ${{ env.STAGING_IMAGE }}
labels: ${{ steps.meta.outputs.labels }}
provenance: false
sbom: false
cache-from: type=gha,scope=base-arm64
cache-to: type=gha,mode=max,scope=base-arm64
docker-manifest:
name: Assemble multi-arch base manifest
needs: [prepare, docker-amd64, docker-arm64]
if: ${{ !inputs.dry_run }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
@@ -431,41 +573,43 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
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
# `imagetools create` combines two single-arch images into a multi-arch
# manifest list under each of the user-facing tags. No layers are
# rebuilt or re-uploaded; this is a registry-side metadata operation.
# The per-arch source images are referenced by digest (not by the
# mutable staging tag) so this is pinned to the exact bytes the
# per-arch jobs just pushed.
- name: Combine arch images into multi-arch tags
env:
AMD64_REF: ghcr.io/kdlbs/kandev@${{ needs.docker-amd64.outputs.digest }}
ARM64_REF: ghcr.io/kdlbs/kandev@${{ needs.docker-arm64.outputs.digest }}
TAGS: ${{ steps.meta.outputs.tags }}
run: |
set -euo pipefail
tag_args=()
while IFS= read -r tag; do
[ -z "$tag" ] && continue
tag_args+=(--tag "$tag")
done <<<"$TAGS"
docker buildx imagetools create "${tag_args[@]}" "$AMD64_REF" "$ARM64_REF"
docker-universal:
name: Build & push universal container image
needs: [prepare, docker]
# ---------------------------------------------------------------------------
# Universal image — same per-arch native split, on top of the base image
# we just promoted. The size gate inspects the amd64 staging tag and aborts
# before the user-facing :universal tags are moved.
# ---------------------------------------------------------------------------
docker-universal-amd64:
name: Build & push universal image (amd64)
needs: [prepare, docker-manifest]
if: ${{ !inputs.dry_run }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # push to GHCR
# Build flow: push to a per-run staging tag, gate on size against the
# staging tag, then `buildx imagetools create` to promote the same
# manifest to the user-facing tags. The promote step is metadata-only -
# no rebuild, no new bytes - so this costs us one extra registry call
# in exchange for never publishing an oversized image as :universal.
#
# Tradeoff: staging tags accumulate in GHCR over time (one per release
# run). Auto-cleanup isn't safe here - `imagetools create` produces a
# byte-identical manifest list when retagging a single source, so the
# staging tag and the promoted user-facing tags share the same package
# version in GHCR. Deleting the version to drop the staging tag would
# also drop :universal / :vX.Y.Z-universal. A separate cleanup workflow
# filtering for versions whose ONLY tags match `universal-staging-*`
# would be the correct way to address this if it becomes a problem.
packages: write
outputs:
digest: ${{ steps.build.outputs.digest }}
env:
STAGING_IMAGE: ghcr.io/kdlbs/kandev:universal-staging-${{ needs.prepare.outputs.tag }}-${{ github.run_id }}
STAGING_IMAGE: ghcr.io/kdlbs/kandev:universal-staging-${{ needs.prepare.outputs.tag }}-${{ github.run_id }}-amd64
steps:
- uses: actions/checkout@v6
with:
@@ -480,59 +624,131 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Build universal on top of the vanilla image we just pushed. Passing
# the exact `:vX.Y.Z` tag (not `:latest`) keeps the pair coherent:
# universal-of-vX.Y.Z always rests on vanilla-vX.Y.Z, even if a later
# release moves `:latest`. The tag pushed here is the staging tag;
# final tags are applied below only if the size gate passes.
- name: Build and push (staging tag)
- name: Docker metadata (labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/kdlbs/kandev
- name: Build and push (universal amd64 staging tag)
id: build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.universal
push: true
platforms: linux/amd64,linux/arm64
platforms: linux/amd64
build-args: |
BASE_IMAGE=ghcr.io/kdlbs/kandev:${{ needs.prepare.outputs.tag }}
tags: ${{ env.STAGING_IMAGE }}
cache-from: type=gha,scope=universal
cache-to: type=gha,mode=max,scope=universal
labels: ${{ steps.meta.outputs.labels }}
# provenance/sbom off so `imagetools inspect --raw` on the staging
# tag returns the plain image manifest, not an OCI index — required
# by the size gate in docker-universal-manifest.
provenance: false
sbom: false
cache-from: type=gha,scope=universal-amd64
cache-to: type=gha,mode=max,scope=universal-amd64
# Universal size gate. Inspects the staging tag - the image is in
# GHCR but under a per-run tag no user pulls. Failing the gate aborts
# before promotion, so the user-facing :universal / :vX.Y.Z-universal
# tags never move to an oversized image. Bump MAX_COMPRESSED_BYTES
# deliberately when adding scope, with a one-sentence justification
# in the commit msg.
docker-universal-arm64:
name: Build & push universal image (arm64)
needs: [prepare, docker-manifest]
if: ${{ !inputs.dry_run }}
runs-on: ubuntu-24.04-arm
permissions:
contents: read
packages: write
outputs:
digest: ${{ steps.build.outputs.digest }}
env:
STAGING_IMAGE: ghcr.io/kdlbs/kandev:universal-staging-${{ needs.prepare.outputs.tag }}-${{ github.run_id }}-arm64
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.prepare.outputs.tag }}
- uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker metadata (labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/kdlbs/kandev
- name: Build and push (universal arm64 staging tag)
id: build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.universal
push: true
platforms: linux/arm64
build-args: |
BASE_IMAGE=ghcr.io/kdlbs/kandev:${{ needs.prepare.outputs.tag }}
tags: ${{ env.STAGING_IMAGE }}
labels: ${{ steps.meta.outputs.labels }}
provenance: false
sbom: false
cache-from: type=gha,scope=universal-arm64
cache-to: type=gha,mode=max,scope=universal-arm64
docker-universal-manifest:
name: Gate size + promote universal manifest
# docker-universal-manifest is included in publish-release's `needs` so
# its size gate (and any per-arch build failure) blocks the release.
needs: [prepare, docker-universal-amd64, docker-universal-arm64]
if: ${{ !inputs.dry_run }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
env:
AMD64_REF: ghcr.io/kdlbs/kandev@${{ needs.docker-universal-amd64.outputs.digest }}
ARM64_REF: ghcr.io/kdlbs/kandev@${{ needs.docker-universal-arm64.outputs.digest }}
steps:
- uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Universal size gate. Inspects the amd64 staging image by digest - the
# image is in GHCR but under a per-run tag no user pulls. Failing the
# gate aborts before promotion, so the user-facing :universal /
# :vX.Y.Z-universal tags never move to an oversized image. Bump
# MAX_COMPRESSED_BYTES deliberately when adding scope, with a
# one-sentence justification in the commit msg. amd64 is the bigger of
# the two arches in practice, so gating on it gives us a single-number
# signal. Per-arch staging builds set provenance: false, so the digest
# points directly at the image manifest (not an OCI index).
- name: Enforce size budget
env:
MAX_COMPRESSED_BYTES: "2684354560" # 2.5 GiB
run: |
set -euo pipefail
# Inspect the multi-arch manifest and sum compressed layer sizes
# for linux/amd64. amd64 is the bigger of the two arches in
# practice, so gating on it gives us a single-number signal.
docker buildx imagetools inspect "$STAGING_IMAGE" --raw \
> /tmp/index.json
AMD64_DIGEST="$(jq -r '
.manifests[]
| select(.platform.architecture == "amd64" and .platform.os == "linux")
| .digest
' /tmp/index.json)"
docker buildx imagetools inspect "ghcr.io/kdlbs/kandev@${AMD64_DIGEST}" --raw \
> /tmp/manifest.json
docker buildx imagetools inspect "$AMD64_REF" --raw > /tmp/manifest.json
TOTAL="$(jq '[.layers[].size] | add' /tmp/manifest.json)"
echo "Universal image (amd64) compressed size: $TOTAL bytes (max: $MAX_COMPRESSED_BYTES)"
if [ "$TOTAL" -gt "$MAX_COMPRESSED_BYTES" ]; then
echo "::error::Universal image exceeds compressed-size budget. Staging tag $STAGING_IMAGE will not be promoted. See docs/images.md inclusion policy." >&2
echo "::error::Universal image exceeds compressed-size budget. Staging tags will not be promoted. See docs/images.md inclusion policy." >&2
exit 1
fi
# Promote: `buildx imagetools create` retargets an existing manifest
# list to new tag(s) without rebuilding or re-uploading layers. Multi-
# arch is preserved. Only runs if the size gate passed (default step
# ordering); if any earlier step failed, the user-facing tags stay at
# whatever they pointed to before this run.
# Promote: combine both arches into a single multi-arch manifest list
# under the user-facing tags. `imagetools create` is a metadata-only
# registry call - no layers are rebuilt or re-uploaded. Per-arch source
# images referenced by digest, not tag, so this is pinned to the bytes
# the previous jobs pushed.
- name: Promote staging to final tags
run: |
set -euo pipefail
@@ -540,14 +756,11 @@ jobs:
--tag "ghcr.io/kdlbs/kandev:${{ needs.prepare.outputs.version }}-universal" \
--tag "ghcr.io/kdlbs/kandev:${{ needs.prepare.outputs.tag }}-universal" \
--tag "ghcr.io/kdlbs/kandev:universal" \
"$STAGING_IMAGE"
"$AMD64_REF" "$ARM64_REF"
publish-release:
name: Publish GitHub release
# docker-universal is included so its size gate (and any build failure)
# blocks the release. Without this, publish-release runs in parallel with
# docker-universal and an oversized universal image still ships a release.
needs: [prepare, build-bundles, docker-universal]
needs: [prepare, build-bundles, docker-universal-manifest]
if: ${{ !inputs.dry_run }}
runs-on: ubuntu-latest
permissions:
@@ -621,6 +834,8 @@ jobs:
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
cache: pnpm
cache-dependency-path: apps/pnpm-lock.yaml
- name: Install dependencies
run: pnpm -C apps install --frozen-lockfile