Explorer
/opt/struktur/buzz/.github/workflows/docker.yml
← Zurück ↓ Download
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:<tag> --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"