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. # # Versioning: the relay is versioned independently of the desktop app via # its own `relay-v*` tags (see `just release-relay`). Desktop `v*` tags and # agent `sprig-v*` tags do NOT publish this image — only `relay-v*` does, so # the relay image version tracks crates/buzz-relay/Cargo.toml, never desktop. # # Triggers: # - push to main → :main + :sha-<7> # - push tags relay-v*.*.* → :{version} + :{major}.{minor} + :{major} # (+ :latest for stable, NOT for prereleases) # - pull_request → build only (no push), cache stays warm # - workflow_dispatch → manual canary (no inputs), or relay-tag rescue # dispatch with version+ref inputs (see below) # # Why workflow_dispatch carries version/ref inputs: # auto-tag-on-release-pr-merge.yml pushes relay-v* with the default # GITHUB_TOKEN, which GitHub deliberately does NOT let fire on:push triggers # (recursion guard). So the push:tags trigger above never runs for releases. # auto-tag instead dispatches this workflow with the bare version + tag ref, # the same rescue release.yml already uses for the desktop lane. On dispatch # github.ref is `main`, so the tag ref is plumbed through explicitly: checkout # pins to inputs.ref, and the semver tags take inputs.version via `value=`. # On the rescue path inputs.version is already bare (e.g. 0.3.0), so the # match=^relay-v(.*)$ regex simply no-ops (it warns, leaving the value # intact) and the bare version flows straight to the semver parser. On a # real push event value= is empty and the match strips relay-v from the ref. # Inputless canary dispatch (version="", ref=main) renders no semver tag. # # The :latest tag tracks the latest STABLE relay release: metadata-action's # `flavor.latest=auto` (its default) emits :latest only for non-prerelease # semver, so relay-v0.3.0-rc.1 publishes :0.3.0-rc.1 without moving :latest, # and main pushes (no semver tag) never produce :latest. on: push: branches: [main] tags: ["relay-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: inputs: version: description: "Semver version e.g. 0.3.0 (no relay-v prefix) — for relay-tag rescue dispatch" required: false ref: description: "Tag/branch/SHA to build, e.g. relay-v0.3.0" required: false default: main # 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: # On workflow_dispatch (relay-tag rescue) build the tagged commit, # not main. Empty string = default ref for push/PR events. ref: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || '' }} 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>, relay releases get the # full semver family. The semver entries carry match=^relay-v(.*)$ # because metadata-action does NOT strip a `relay-v` prefix on its # own — it only strips refs/tags/, then runs the raw ref through # semver.valid(), which rejects "relay-v0.3.0". The match capture # group feeds the bare version to the semver parser. value= supplies # the version on the auto-tag rescue dispatch (github.ref is `main` # there, not the tag): it is already bare, so match no-ops (warns, # value intact) and the bare version validates as-is. On push value= # is empty, so the ref drives it and match strips relay-v — push # behavior is unchanged. Pull requests get nothing (push: false # below). :latest is intentionally absent — flavor.latest defaults to # `auto`, which adds :latest for stable semver tags only (not # prereleases, not main pushes). tags: | type=ref,event=branch,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }} type=sha,prefix=sha-,format=short,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }} type=semver,pattern={{version}},match=^relay-v(.*)$,value=${{ inputs.version }} type=semver,pattern={{major}}.{{minor}},match=^relay-v(.*)$,value=${{ inputs.version }} type=semver,pattern={{major}},match=^relay-v(.*)$,value=${{ inputs.version }} 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: | ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && format('type=registry,ref={0}-buildcache:{1},mode=max,compression=zstd', env.IMAGE_NAME, matrix.arch) || '' }} - 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 }} # Must mirror the build job's tag matrix exactly — the merge job # re-derives tags to stamp them onto the multi-arch manifest. See # the build job's `meta` step for why match=^relay-v(.*)$, why # value=${{ inputs.version }} carries the rescue-dispatch version, # and why :latest is left to flavor.latest=auto. tags: | type=ref,event=branch,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }} type=sha,prefix=sha-,format=short,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }} type=semver,pattern={{version}},match=^relay-v(.*)$,value=${{ inputs.version }} type=semver,pattern={{major}}.{{minor}},match=^relay-v(.*)$,value=${{ inputs.version }} type=semver,pattern={{major}},match=^relay-v(.*)$,value=${{ inputs.version }} - 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: --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"