ci(docker): publish public ghcr.io/block/buzz image (native multi-arch) (#986)

Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
tlongwell-block
2026-06-12 11:20:44 -04:00
committed by GitHub
co-authored by npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc
parent 84f499cb6e
commit 1fa63badad
3 changed files with 383 additions and 33 deletions
+50 -3
View File
@@ -1,7 +1,54 @@
# Build outputs
target/
**/target/
desktop/src-tauri/target/
# Native non-relay UI builds (not part of the public image)
desktop/
mobile/
# Node artifacts (built inside the image; host outputs would invalidate cache)
node_modules/
**/node_modules/
web/dist/
# VCS, IDE, scratch
.git/
.github/
.gitignore
.gitattributes
.vscode/
.idea/
.scratch/
*.md
!README.md
.env*
# Local environment / secrets — never want these in the build context
.env
.env.*
!.env.example
*.pem
*.key
secrets/
# OS junk
.DS_Store
Thumbs.db
# CI artifacts and tooling that the image doesn't need
.cache/
coverage/
dist/
playwright-report/
test-results/
bin/
sidecars/
# Docs the image doesn't ship. examples/ stays — examples/countdown-bot is
# a Cargo workspace member, so cargo-chef needs its manifest. Markdown files
# inside crates (Cargo.toml `readme = ...`) likewise need to be present to
# avoid manifest warnings; the savings from excluding them are negligible.
docs/
# Old Dockerfile noise (this Dockerfile is THE source of truth now)
docker-compose.yml
docker-compose.*.yml
prometheus.yml
+254
View File
@@ -0,0 +1,254 @@
name: Docker image
# Builds and publishes the public Buzz relay image as ghcr.io/block/buzz.
#
# Strategy: each architecture builds on its native runner (ubuntu-24.04 for
# amd64, ubuntu-24.04-arm for arm64), pushes to GHCR by digest, then a final
# job stitches the per-arch digests into a single multi-arch manifest.
# This avoids QEMU emulation (~10× slower for Rust) at zero cost on free
# GitHub-hosted runners.
#
# Triggers:
# - push to main → :main + :sha-<7>
# - push tags v*.*.* → :latest + :{version} + :{major}.{minor} + :{major}
# - pull_request → build only (no push), cache stays warm
# - workflow_dispatch → manual canary
on:
push:
branches: [main]
tags: ["v[0-9]*"]
pull_request:
paths:
- "Dockerfile"
- ".dockerignore"
- ".github/workflows/docker.yml"
- "Cargo.toml"
- "Cargo.lock"
- "rust-toolchain.toml"
- "crates/**"
- "web/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "patches/**"
workflow_dispatch:
# One image build per ref; cancel superseded PR builds, but never cancel
# tag/main builds (publishing must not be aborted mid-flight).
concurrency:
group: docker-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref_type == 'branch' && github.event_name == 'pull_request' }}
permissions: {}
env:
# Single source of truth for the image name. Set GHCR_IMAGE as a repo
# variable to override (e.g., for forks that want to push to their own
# namespace without forking this file).
IMAGE_NAME: ${{ vars.GHCR_IMAGE != '' && vars.GHCR_IMAGE || 'ghcr.io/block/buzz' }}
jobs:
build:
name: Build (${{ matrix.platform }})
runs-on: ${{ matrix.runner }}
timeout-minutes: 60
permissions:
contents: read
packages: write # push to GHCR
id-token: write # OIDC for build provenance attestation
attestations: write
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-24.04
arch: amd64
- platform: linux/arm64
runner: ubuntu-24.04-arm
arch: arm64
outputs:
# Used downstream by `merge` to stitch the manifest.
version: ${{ steps.meta.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
with:
# Default parallelism of 4 OOMs the 7GB GitHub runner during Rust
# compiles (see moby/buildkit#3969). Vaultwarden hit this; we will
# too without the cap.
buildkitd-config-inline: |
[worker.oci]
max-parallelism = 2
- name: Log in to GHCR
# Skip on pull_request from forks — no GHCR creds, build-only.
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: ${{ env.IMAGE_NAME }}
# Tag matrix — every main commit gets sha-<7>, releases get full
# semver family. Pull requests get nothing (push: false below).
tags: |
type=ref,event=branch
type=sha,prefix=sha-,format=short
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
labels: |
org.opencontainers.image.title=Buzz
org.opencontainers.image.description=WebSocket relay server for the Buzz communications platform
org.opencontainers.image.licenses=Apache-2.0
- name: Build and push by digest
id: build
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: ./Dockerfile
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
# Push by digest, not by tag — the merge job assembles the tags
# into one multi-arch manifest. This is what makes the native-arm
# matrix possible.
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }}
cache-from: |
type=registry,ref=${{ env.IMAGE_NAME }}-buildcache:${{ matrix.arch }}
cache-to: |
type=registry,ref=${{ env.IMAGE_NAME }}-buildcache:${{ matrix.arch }},mode=max,compression=zstd
- name: Export digest
if: github.event_name != 'pull_request'
env:
DIGEST: ${{ steps.build.outputs.digest }}
run: |
mkdir -p /tmp/digests
touch "/tmp/digests/${DIGEST#sha256:}"
- name: Upload digest
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: digests-${{ matrix.arch }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
name: Merge multi-arch manifest
if: github.event_name != 'pull_request'
runs-on: ubuntu-24.04
needs: build
timeout-minutes: 15
permissions:
contents: read
packages: write # push the merged manifest
id-token: write # OIDC for provenance attestation on the manifest
attestations: write
steps:
- name: Download all per-arch digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Log in to GHCR
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=sha,prefix=sha-,format=short
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Create and push manifest list
id: manifest
working-directory: /tmp/digests
env:
IMAGE_NAME: ${{ env.IMAGE_NAME }}
META_TAGS: ${{ steps.meta.outputs.tags }}
run: |
set -euo pipefail
# Build -t flags from the metadata-action output.
tags=()
while IFS= read -r tag; do
[ -n "$tag" ] && tags+=("-t" "$tag")
done <<< "$META_TAGS"
# Build the digest refs from the per-arch artifacts.
digests=()
for digest in *; do
digests+=("${IMAGE_NAME}@sha256:${digest}")
done
docker buildx imagetools create "${tags[@]}" "${digests[@]}"
# Capture the merged manifest digest for the attestation step.
first_tag=$(echo "$META_TAGS" | head -n1)
merged_digest=$(docker buildx imagetools inspect "$first_tag" \
--format '{{json .Manifest}}' | jq -r '.digest')
echo "digest=${merged_digest}" >> "$GITHUB_OUTPUT"
- name: Attest provenance for the merged image
# Sigstore-signed in-toto attestation, verifiable with:
# gh attestation verify oci://ghcr.io/block/buzz:<tag> --owner block
uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0
with:
subject-name: ${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.manifest.outputs.digest }}
push-to-registry: true
- name: Summary
env:
IMAGE_NAME: ${{ env.IMAGE_NAME }}
MERGED_DIGEST: ${{ steps.manifest.outputs.digest }}
META_TAGS: ${{ steps.meta.outputs.tags }}
run: |
{
echo "### Published \`${IMAGE_NAME}\`"
echo
echo "**Digest:** \`${MERGED_DIGEST}\`"
echo
echo "**Tags:**"
echo '```'
echo "${META_TAGS}"
echo '```'
echo
echo "Verify provenance:"
echo '```'
echo "gh attestation verify oci://${IMAGE_NAME}@${MERGED_DIGEST} --owner block"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
+79 -30
View File
@@ -1,46 +1,95 @@
# ── Build stage (Rust) ──────────────────────────────────────
# Hard-code --platform to prevent exec format error on ARM Macs.
FROM --platform=linux/amd64 rust:1.95-bookworm AS builder
# syntax=docker/dockerfile:1.7
#
# Public Buzz relay image — published as ghcr.io/block/buzz:<tag>.
#
# Builds the `buzz-relay` binary (Rust 1.95) and the `buzz-web` static bundle
# (pnpm + vite), then assembles them into a small debian-slim runtime with
# `git` available (the relay shells out to git for repo hydrate / receive-pack
# / upload-pack — see crates/buzz-relay/src/api/git).
#
# Multi-arch is handled by running this same Dockerfile on native amd64 and
# native arm64 runners (see .github/workflows/docker.yml). The Dockerfile
# itself is platform-agnostic; do not add --platform pins.
ARG RUST_VERSION=1.95
ARG NODE_VERSION=24
ARG DEBIAN_VERSION=bookworm
# ─── Stage 1: cargo-chef base ───────────────────────────────────────────────
FROM rust:${RUST_VERSION}-${DEBIAN_VERSION} AS chef
RUN cargo install cargo-chef --locked --version 0.1.71
WORKDIR /build
# ─── Stage 2: plan dependency graph ─────────────────────────────────────────
# Only the manifests are needed to compute the recipe; this layer rebuilds
# only when Cargo.{toml,lock} or crate manifests change, not on every source
# edit.
FROM chef AS planner
COPY . .
RUN cargo build --release -p buzz-relay \
RUN cargo chef prepare --recipe-path recipe.json
# ─── Stage 3: cook dependencies, then build the binary ──────────────────────
FROM chef AS builder
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
pkg-config \
libssl-dev \
ca-certificates \
git \
&& rm -rf /var/lib/apt/lists/*
COPY --from=planner /build/recipe.json recipe.json
# Cook the full workspace recipe — relay deps include workspace siblings, so
# scoping to -p buzz-relay misses transitive deps and re-builds them later.
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
RUN cargo build --release --locked -p buzz-relay --bin buzz-relay \
&& strip target/release/buzz-relay
# ── Web build stage (Node/pnpm) ────────────────────────────
FROM --platform=linux/amd64 node:24-bookworm-slim AS web-builder
# ─── Stage 4: web bundle (pnpm + vite) ──────────────────────────────────────
# Independent of the Rust layers so a CSS change doesn't bust Rust cache and
# vice versa.
FROM node:${NODE_VERSION}-${DEBIAN_VERSION}-slim AS web-builder
WORKDIR /build
RUN corepack enable
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY patches/ patches/
COPY web/package.json web/
RUN pnpm install --frozen-lockfile --filter buzz-web
COPY web/ web/
RUN corepack enable && pnpm install --frozen-lockfile --filter buzz-web
RUN pnpm -C web build
# ── Runtime stage ───────────────────────────────────────────
FROM --platform=linux/amd64 debian:bookworm-slim
# ─── Stage 5: runtime ───────────────────────────────────────────────────────
FROM debian:${DEBIAN_VERSION}-slim AS runtime
# CAKE: non-root UID 1000 (numeric, not username)
RUN groupadd -g 1000 buzz && useradd -u 1000 -g buzz -m buzz
# OCI annotations: required for GHCR to auto-link the image to this repo and
# inherit its visibility. org.opencontainers.image.source is the load-bearing
# one — without it GHCR keeps the image private even when the repo is public.
LABEL org.opencontainers.image.title="Buzz" \
org.opencontainers.image.description="WebSocket relay server for the Buzz communications platform" \
org.opencontainers.image.source="https://github.com/block/buzz" \
org.opencontainers.image.url="https://github.com/block/buzz" \
org.opencontainers.image.documentation="https://github.com/block/buzz#readme" \
org.opencontainers.image.licenses="Apache-2.0"
# CAKE: writable dirs
RUN mkdir -p /cache /tmp && chown buzz:buzz /cache /tmp
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
git \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --system --gid 1000 buzz \
&& useradd --system --uid 1000 --gid 1000 --home-dir /var/lib/buzz \
--create-home --shell /usr/sbin/nologin buzz
# git: relay shells out to `git` for hydrate/receive-pack/upload-pack (S3-backed repos)
# socat: Istio abstract→file socket bridge
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates git socat && rm -rf /var/lib/apt/lists/*
COPY --from=builder /build/target/release/buzz-relay /usr/local/bin/buzz-relay
COPY --from=web-builder /build/web/dist /srv/buzz/web
COPY --from=builder /build/target/release/buzz-relay /code/buzz-relay
COPY --from=web-builder /build/web/dist /code/web
COPY script/start /code/start
RUN chmod +x /code/start
ENV BUZZ_WEB_DIR=/srv/buzz/web
ENV BUZZ_WEB_DIR="/code/web"
# 3000: app (WS + REST + web UI) · 8080: /_liveness, /_readiness · 9102: /metrics
EXPOSE 3000 8080 9102
# CAKE: required Envoy env vars (overridden at runtime by CAKE).
ENV ENVOY_ADMIN_SOCKET_PATH="@envoy-admin.sock" \
ENVOY_INGRESS_PORT="20001" \
ENVOY_HTTP_EGRESS_SOCKET_PATH="@egress.sock" \
ENVOY_DATADOG_PORT="3030" \
CASH_FRAMEWORK="rust"
USER buzz:buzz
WORKDIR /var/lib/buzz
USER 1000
ENTRYPOINT ["/code/start"]
ENTRYPOINT ["/usr/local/bin/buzz-relay"]