name: Docker image # Builds and publishes the public Buzz relay images as ghcr.io/block/buzz. # Normal tags contain stripped binaries; matching debug-* tags contain the same # optimized binaries with line-table debug information for native profilers. # # 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> # + :debug-main + :debug-sha-<7> # - push tags relay-v*.*.* → :{version} + :{major}.{minor} + :{major} # + matching :debug-* tags # (+ :latest/:debug-latest for stable releases) # - pull_request → build only (no push), cache stays warm # - workflow_dispatch → manual relay-tag rescue at the tag itself # # Why workflow_dispatch carries a version input: # Normal releases arrive through the push:tags trigger above. The input is # retained only for an operator to rerun publication manually at an immutable # relay tag. The workflow rejects a dispatch whose github.ref, checked-out # HEAD, and relay-v tag do not resolve to one commit. # 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. # # 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" - "Dockerfile.push-gateway" - ".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: true # 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 steps: - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 persist-credentials: false - name: Verify tag-bound release source if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch' env: INPUT_VERSION: ${{ inputs.version }} run: | VERSION="${INPUT_VERSION:-${GITHUB_REF_NAME#relay-v}}" scripts/verify-release-ref.sh relay-v "$VERSION" - 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 a manual 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 release image by digest id: build-release uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . file: ./Dockerfile target: runtime 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: Build and push debug image by digest id: build-debug uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . file: ./Dockerfile target: runtime-debug platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} 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 }} - name: Export release and debug digests if: github.event_name != 'pull_request' env: RELEASE_DIGEST: ${{ steps.build-release.outputs.digest }} DEBUG_DIGEST: ${{ steps.build-debug.outputs.digest }} run: | mkdir -p /tmp/digests-release /tmp/digests-debug touch "/tmp/digests-release/${RELEASE_DIGEST#sha256:}" touch "/tmp/digests-debug/${DEBUG_DIGEST#sha256:}" - name: Upload release digest if: github.event_name != 'pull_request' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: digests-release-${{ matrix.arch }} path: /tmp/digests-release/* if-no-files-found: error retention-days: 1 - name: Upload debug digest if: github.event_name != 'pull_request' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: digests-debug-${{ matrix.arch }} path: /tmp/digests-debug/* if-no-files-found: error retention-days: 1 merge: name: Merge ${{ matrix.variant }} 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 strategy: fail-fast: false matrix: include: - variant: release tag_prefix: "" - variant: debug tag_prefix: debug- steps: - name: Download all per-arch digests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: /tmp/digests pattern: digests-${{ matrix.variant }}-* 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. flavor: | latest=auto prefix=${{ matrix.tag_prefix }},onlatest=true tags: | type=ref,event=branch,enable=${{ github.event_name != 'workflow_dispatch' || inputs.version == '' }} type=sha,prefix=${{ matrix.tag_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@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: subject-name: ${{ env.IMAGE_NAME }} subject-digest: ${{ steps.manifest.outputs.digest }} push-to-registry: true - name: Summary env: IMAGE_NAME: ${{ env.IMAGE_NAME }} VARIANT: ${{ matrix.variant }} MERGED_DIGEST: ${{ steps.manifest.outputs.digest }} META_TAGS: ${{ steps.meta.outputs.tags }} run: | { echo "### Published \`${IMAGE_NAME}\` (${VARIANT})" 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" push-gateway-build: name: Build public push gateway (${{ matrix.platform }}) runs-on: ${{ matrix.runner }} timeout-minutes: 60 permissions: contents: read packages: 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 steps: - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 persist-credentials: false - name: Verify tag-bound release source if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch' env: INPUT_VERSION: ${{ inputs.version }} run: | VERSION="${INPUT_VERSION:-${GITHUB_REF_NAME#relay-v}}" scripts/verify-release-ref.sh relay-v "$VERSION" - name: Set up Docker Buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 with: buildkitd-config-inline: | [worker.oci] max-parallelism = 2 - name: Log in to GHCR 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: ghcr.io/block/buzz-push-gateway labels: | org.opencontainers.image.title=Buzz Push Gateway org.opencontainers.image.description=Capability-gated APNs last hop for Buzz 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.push-gateway platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} outputs: type=image,name=ghcr.io/block/buzz-push-gateway,push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }} cache-from: type=registry,ref=ghcr.io/block/buzz-push-gateway-buildcache:${{ matrix.arch }} cache-to: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && format('type=registry,ref=ghcr.io/block/buzz-push-gateway-buildcache:{0},mode=max,compression=zstd', matrix.arch) || '' }} - name: Export digest if: github.event_name != 'pull_request' env: DIGEST: ${{ steps.build.outputs.digest }} run: mkdir -p /tmp/gateway-digests && touch "/tmp/gateway-digests/${DIGEST#sha256:}" - name: Upload digest if: github.event_name != 'pull_request' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: gateway-digests-${{ matrix.arch }} path: /tmp/gateway-digests/* if-no-files-found: error retention-days: 1 push-gateway-merge: name: Publish public push gateway image if: github.event_name != 'pull_request' runs-on: ubuntu-24.04 needs: push-gateway-build timeout-minutes: 15 permissions: contents: read packages: write id-token: write attestations: write steps: - name: Download per-arch digests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: /tmp/gateway-digests pattern: gateway-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: ghcr.io/block/buzz-push-gateway 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 }} - name: Merge and publish manifest id: manifest working-directory: /tmp/gateway-digests env: META_TAGS: ${{ steps.meta.outputs.tags }} run: | set -euo pipefail tags=(); while IFS= read -r tag; do [ -n "$tag" ] && tags+=("-t" "$tag"); done <<< "$META_TAGS" digests=(); for digest in *; do digests+=("ghcr.io/block/buzz-push-gateway@sha256:${digest}"); done docker buildx imagetools create "${tags[@]}" "${digests[@]}" first_tag=$(echo "$META_TAGS" | head -n1) digest=$(docker buildx imagetools inspect "$first_tag" --format '{{json .Manifest}}' | jq -r '.digest') echo "digest=${digest}" >> "$GITHUB_OUTPUT" - name: Attest gateway image provenance uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: subject-name: ghcr.io/block/buzz-push-gateway subject-digest: ${{ steps.manifest.outputs.digest }} push-to-registry: true - name: Gateway publication summary env: GATEWAY_DIGEST: ${{ steps.manifest.outputs.digest }} GATEWAY_TAGS: ${{ steps.meta.outputs.tags }} run: | set -euo pipefail { echo "### Published \`ghcr.io/block/buzz-push-gateway\`" echo printf "**Digest:** \`%s\`\n" "$GATEWAY_DIGEST" echo echo '**Tags:**' echo "\`\`\`" printf '%s\n' "$GATEWAY_TAGS" echo "\`\`\`" echo echo 'Verify provenance before deployment:' echo "\`\`\`" printf 'gh attestation verify oci://ghcr.io/block/buzz-push-gateway@%s --owner block\n' "$GATEWAY_DIGEST" echo "\`\`\`" } >> "$GITHUB_STEP_SUMMARY"