# Publish Docker images to GitHub Container Registry (GHCR).
#
# Triggers:
#   - GitHub Release published (`v*` tag, e.g. `v0.3.0`) → :0.3.0, :0.3, :stable, :sha-
#     (never :latest; a tag push alone publishes nothing, so release images wait
#     until the reviewed Electron draft release is published)
#   - push to main branch                          → :latest, :main, :sha- (rolling preview)
#   - workflow_dispatch                            → :sha- only (ad-hoc test build), or
#     with backfill_release=true the release tags for an existing `vX.Y.Z` tag.
#     Releases published by electron-release.yml (GITHUB_TOKEN, which cannot
#     fire `release` events) dispatch that backfill path automatically.
#
# Tag ↔ image mapping (versioning hard rule, owner-set 2026-06-11:
# :latest IS the preview channel; stable users pin :stable or a version tag)
#   :latest   — rolling preview: latest commit on main (never a release build)
#   :main     — alias of the same rolling main build (kept for back-compat)
#   :stable   — most recent versioned release (set when a non-prerelease is published)
#   :beta     — most recent `vX.Y.Z-beta.N` prerelease (moves only on beta releases;
#               a beta also gets its exact :X.Y.Z-beta.N tag, never :X.Y or :stable)
#   :0.3.6    — exact version from the git tag
#   :0.3      — major.minor floating tag (updated on every patch within the minor)
#   :sha-xxxx — specific commit SHA; produced by every build
#
# ROCm/AMD GPU variant (#1165) — same semantics, `-rocm` suffixed, built by the
# build-and-push-rocm job from the same Dockerfile via the BASE_IMAGE build-arg:
#   :rocm           — rolling preview from main (the ROCm analogue of :latest)
#   :stable-rocm    — most recent versioned release, ROCm build
#   :beta-rocm      — most recent beta prerelease, ROCm build
#   :0.3.6-rocm     — exact version, ROCm build
#   :0.3-rocm       — major.minor floating tag, ROCm build
#   :sha-xxxx-rocm  — specific commit SHA, ROCm build
#
# arm64 NVIDIA CUDA variant (#2831: DGX Spark / GB10, GH200, GB200) — same
# semantics, `-cuda-arm64` suffixed, built natively on an arm64 runner by the
# build-and-push-cuda-arm64 job (BASE_IMAGE=cuda-arm64-base):
#   :cuda-arm64          — rolling preview from main
#   :stable-cuda-arm64   — most recent versioned release
#   :beta-cuda-arm64     — most recent beta prerelease
#   :0.3.6-cuda-arm64    — exact version
#   :0.3-cuda-arm64      — major.minor floating tag
#   :sha-xxxx-cuda-arm64 — specific commit SHA
# The un-suffixed and `-rocm` tags stay linux/amd64 only.
#
# Images land at: ghcr.io/debpalash/voicestudio, the legacy GHCR alias
# ghcr.io/debpalash/omnivoice-studio, and docker.io/palashdeb/omnivoice-studio
# (Docker Hub push gated on the DOCKERHUB_USERNAME/DOCKERHUB_TOKEN secrets;
# if unset the build still pushes to GHCR.)
#
# On main pushes the Docker Hub repository overview is also synced from
# deploy/dockerhub-overview.md (source of truth for the hub.docker.com page).
#
# NOTE: the Docker image is the headless web-server build of VoiceStudio (FastAPI
# backend + pre-built React frontend served over HTTP).  The desktop
# auto-updater and its update-channel toggle are desktop-only features; they do
# NOT apply to the Docker image.

name: Docker (GHCR)

on:
  push:
    branches: [main]
    # A merge that only touches these cannot change what the image contains, so it
    # does not rebuild two ~35 minute images. deploy/dockerhub-overview.md (the Docker
    # Hub page, synced by this workflow), the Dockerfile and README.md (copied into
    # the image) are deliberately NOT listed. Release and manual runs are unaffected.
    paths-ignore:
      - "docs/**"
      - "mobile/**"
      - "changelog.d/**"
      - "CHANGELOG.md"
      - ".github/ISSUE_TEMPLATE/**"
      - ".github/*.md"
  release:
    types: [published]
  workflow_dispatch:
    inputs:
      backfill_release:
        description: Copy an existing release into canonical GHCR/Docker Hub tags
        type: boolean
        default: false
      release_ref:
        description: Release tag to build when backfilling (for example v0.5.6)
        type: string
        required: false
      promote_stable:
        description: Also move stable tags to this backfilled release
        type: boolean
        default: false

permissions:
  contents: read
  packages: write

# Only the newest main push needs to build the rolling :latest/:main images, so a
# newer push cancels an older one. Releases and manual backfills never cancel
# anything: each gets its own group.
concurrency:
  group: docker-${{ github.event_name == 'push' && 'main-push' || github.run_id }}
  cancel-in-progress: ${{ github.event_name == 'push' }}

env:
  REGISTRY: ghcr.io
  # Keep registry coordinates explicit. VoiceStudio is the canonical GHCR
  # path; the old path remains a compatibility alias during the migration.
  IMAGE_NAME: debpalash/voicestudio
  # Compatibility alias: keep existing deployments on the same tag stream.
  LEGACY_IMAGE_NAME: debpalash/omnivoice-studio
  DOCKERHUB_IMAGE: palashdeb/omnivoice-studio
  FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
  # BuildKit layer cache. It lives in GHCR, not the Actions cache: the GHA cache is
  # capped at 10 GB per repository and these layers (one is 4 GB, 5+ GB in total)
  # evicted the iOS native-runtime, Gradle and bun caches, so mobile CI kept paying
  # a 17 minute rebuild. A separate package keeps the cache out of the image's tags.
  BUILDCACHE_IMAGE: ghcr.io/debpalash/voicestudio-buildcache

jobs:
  build-and-push:
    # Only version releases publish images; other release tags are ignored.
    if: github.event_name != 'release' || startsWith(github.event.release.tag_name, 'v')
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
        with:
          ref: ${{ inputs.backfill_release && inputs.release_ref || github.sha }}

      - name: Validate release backfill ref
        if: inputs.backfill_release
        shell: bash
        env:
          RELEASE_REF: ${{ inputs.release_ref }}
        run: |
          [[ "$RELEASE_REF" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-beta\.[0-9]+)?$ ]]
          test "$(git rev-parse HEAD)" = "$(git rev-parse "refs/tags/${RELEASE_REF}^{commit}")"
          TAG_VERSION="${RELEASE_REF#v}"
          PACKAGE_VERSION="$(python3 -c 'import json; print(json.load(open("package.json"))["version"])')"
          if [[ "$(printf '%s\n' "$TAG_VERSION" '0.5.6' | sort -V | tail -n1)" == '0.5.6' ]]; then
            test "$PACKAGE_VERSION" = '1.0.0'
          else
            test "$PACKAGE_VERSION" = "$TAG_VERSION"
          fi

      - name: Resolve image source commit
        id: source
        shell: bash
        run: |
          echo "short_sha=$(git rev-parse --short=7 HEAD)" >> "$GITHUB_OUTPUT"
          echo "commit=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"

      # Both image builds run right at the runner's disk ceiling (CUDA hit
      # ENOSPC 2026-07-16 morning; ROCm hit it the same afternoon even with
      # the original reclaim list). Reclaim everything these jobs can never
      # use — ~40-45 GB total. Keep this list identical in both jobs.
      - name: Free runner disk space
        run: |
          sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \
            /usr/local/.ghcup /opt/hostedtoolcache /usr/share/swift \
            /usr/local/share/boost /usr/local/lib/node_modules
          sudo docker image prune --all --force
          sudo apt-get clean
          df -h /

      # QEMU enables cross-platform builds (arm64 on x64 runner).
      # Skipped for now — only building linux/amd64.
      # - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0

      - name: Log in to GHCR
        uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      # Docker Hub login — only when the secret is present, so forks / runs
      # without the credential still publish to GHCR.
      - name: Check Docker Hub credentials
        id: dockerhub
        run: echo "enabled=${{ secrets.DOCKERHUB_TOKEN != '' }}" >> "$GITHUB_OUTPUT"

      - name: Log in to Docker Hub
        if: steps.dockerhub.outputs.enabled == 'true'
        uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      # Tag strategy (`:sha-<short>` is emitted on every trigger):
      #   v0.3.6 release published → :0.3.6, :0.3, :stable, :sha-
      #   main branch push         → :latest, :main, :sha-
      #   workflow_dispatch        → :sha- only (backfill retags below)
      #
      # Every mutable-tag rule is gated on its event, so a manual
      # workflow_dispatch can only ever produce a throwaway `:sha-` tag (the
      # stale-:latest fix from #249/#251). latest=false stops metadata-action's
      # `auto` default from adding :latest to release builds; :latest is the
      # main preview channel only. :stable excludes prereleases.
      - name: Extract metadata (tags, labels)
        id: meta
        uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
        with:
          # Same tag set applied to both registries. The Docker Hub line is
          # blank when the secret is unset, so metadata-action emits GHCR-only
          # tags in that case.
          images: |
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
            ${{ env.REGISTRY }}/${{ env.LEGACY_IMAGE_NAME }}
            ${{ steps.dockerhub.outputs.enabled == 'true' && env.DOCKERHUB_IMAGE || '' }}
          flavor: |
            latest=false
          tags: |
            type=semver,pattern={{version}},enable=${{ github.event_name == 'release' }}
            type=semver,pattern={{major}}.{{minor}},enable=${{ github.event_name == 'release' && !contains(github.ref, '-') }}
            type=raw,value=beta,enable=${{ github.event_name == 'release' && github.event.release.prerelease && contains(github.ref, '-beta.') }}
            type=raw,value=stable,enable=${{ github.event_name == 'release' && !github.event.release.prerelease && !contains(github.ref, '-') }}
            type=raw,value=latest,enable=${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
            type=raw,value=main,enable=${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
            type=raw,value=sha-${{ steps.source.outputs.short_sha }}

      - name: Build and push
        uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
        with:
          context: .
          file: deploy/Dockerfile
          build-args: |
            BUILD_COMMIT=${{ steps.source.outputs.commit }}
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=registry,ref=${{ env.BUILDCACHE_IMAGE }}:buildkit
          # A failed cache export must never fail (or skip) the image publish.
          cache-to: type=registry,ref=${{ env.BUILDCACHE_IMAGE }}:buildkit,mode=max,ignore-error=true

      # One-time migration path for releases that predate the canonical GHCR
      # coordinate. The explicit ref is validated against package.json before
      # the freshly built sha tag is promoted in both registries.
      - name: Backfill canonical GHCR release tags
        if: inputs.backfill_release
        shell: bash
        env:
          DOCKERHUB_ENABLED: ${{ steps.dockerhub.outputs.enabled }}
          PROMOTE_STABLE: ${{ inputs.promote_stable }}
          RELEASE_REF: ${{ inputs.release_ref }}
          SOURCE_TAG: sha-${{ steps.source.outputs.short_sha }}
        run: |
          VERSION="${RELEASE_REF#v}"
          if [[ "$VERSION" == *-beta.* ]]; then
            # A beta never moves :stable or the floating major.minor tag.
            test "$PROMOTE_STABLE" != "true"
            TAGS=("$VERSION" beta)
          else
            MINOR="${VERSION%.*}"
            TAGS=("$VERSION" "$MINOR")
            if [[ "$PROMOTE_STABLE" == "true" ]]; then TAGS=(stable "${TAGS[@]}"); fi
          fi
          for IMAGE in "$IMAGE_NAME" "$LEGACY_IMAGE_NAME"; do
            for TAG in "${TAGS[@]}"; do
              docker buildx imagetools create \
                --tag "${REGISTRY}/${IMAGE}:${TAG}" \
                "${REGISTRY}/${IMAGE}:${SOURCE_TAG}"
            done
          done
          if [[ "$DOCKERHUB_ENABLED" == "true" ]]; then
            for TAG in "${TAGS[@]}"; do
              docker buildx imagetools create \
                --tag "${DOCKERHUB_IMAGE}:${TAG}" \
                "${DOCKERHUB_IMAGE}:${SOURCE_TAG}"
            done
          fi

      # Sync the Docker Hub repository overview from deploy/dockerhub-overview.md.
      # Only on main pushes (the overview tracks the rolling preview) and only
      # when Docker Hub creds are present, mirroring the push gating above.
      #
      # continue-on-error: the overview text is cosmetic, and the description
      # PATCH 403s unless DOCKERHUB_TOKEN carries description-edit scope (many
      # fine-grained Docker Hub tokens that can push still can't edit the
      # description). The image build+push is what matters — a creds-scope
      # mismatch on this cosmetic step must not fail the whole Docker run. To
      # actually sync the overview, use a token with read/write (incl.
      # description) scope, or the account password.
      - name: Update Docker Hub description
        if: steps.dockerhub.outputs.enabled == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main'
        continue-on-error: true
        uses: peter-evans/dockerhub-description@432a30c9e07499fd01da9f8a49f0faf9e0ca5b77 # v4.0.2
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}
          repository: ${{ env.DOCKERHUB_IMAGE }}
          short-description: "Local ElevenLabs alternative: voice cloning, design & video dubbing in 646 languages. No API keys."
          readme-filepath: ./deploy/dockerhub-overview.md

  # ── ROCm/AMD GPU image variant (#1165) ──────────────────────────────────
  # Same Dockerfile, ROCm PyTorch base swapped in via the BASE_IMAGE
  # build-arg; tags mirror the CUDA job's with a `-rocm` suffix (table in the
  # header comment). Kept as a SEPARATE job — not a matrix leg or a second
  # build step — so it gets a full runner disk to itself: the ROCm base alone
  # is ~10 GB compressed / ~25 GB unpacked.
  build-and-push-rocm:
    # Only version releases publish images; other release tags are ignored.
    if: github.event_name != 'release' || startsWith(github.event.release.tag_name, 'v')
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
        with:
          ref: ${{ inputs.backfill_release && inputs.release_ref || github.sha }}

      - name: Validate release backfill ref
        if: inputs.backfill_release
        shell: bash
        env:
          RELEASE_REF: ${{ inputs.release_ref }}
        run: |
          [[ "$RELEASE_REF" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-beta\.[0-9]+)?$ ]]
          test "$(git rev-parse HEAD)" = "$(git rev-parse "refs/tags/${RELEASE_REF}^{commit}")"
          TAG_VERSION="${RELEASE_REF#v}"
          PACKAGE_VERSION="$(python3 -c 'import json; print(json.load(open("package.json"))["version"])')"
          if [[ "$(printf '%s\n' "$TAG_VERSION" '0.5.6' | sort -V | tail -n1)" == '0.5.6' ]]; then
            test "$PACKAGE_VERSION" = '1.0.0'
          else
            test "$PACKAGE_VERSION" = "$TAG_VERSION"
          fi

      - name: Resolve image source commit
        id: source
        shell: bash
        run: |
          echo "short_sha=$(git rev-parse --short=7 HEAD)" >> "$GITHUB_OUTPUT"
          echo "commit=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"

      # The ROCm base (~25 GB unpacked) plus build layers need every GB.
      # Same reclaim list as the CUDA job above — keep them identical
      # (2026-07-16: ROCm hit ENOSPC with the shorter list).
      - name: Free runner disk space
        run: |
          sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \
            /usr/local/.ghcup /opt/hostedtoolcache /usr/share/swift \
            /usr/local/share/boost /usr/local/lib/node_modules
          sudo docker image prune --all --force
          sudo apt-get clean
          df -h /

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0

      - name: Log in to GHCR
        uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      # Same Docker Hub gating as the CUDA job: push there only when the
      # secret exists, so forks still publish to GHCR.
      - name: Check Docker Hub credentials
        id: dockerhub
        run: echo "enabled=${{ secrets.DOCKERHUB_TOKEN != '' }}" >> "$GITHUB_OUTPUT"

      - name: Log in to Docker Hub
        if: steps.dockerhub.outputs.enabled == 'true'
        uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      # Mirrors the CUDA job's tag rules (incl. the workflow_dispatch and
      # prerelease gating) with a `-rocm` suffix on every tag.
      - name: Extract metadata (tags, labels)
        id: meta
        uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
        with:
          images: |
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
            ${{ env.REGISTRY }}/${{ env.LEGACY_IMAGE_NAME }}
            ${{ steps.dockerhub.outputs.enabled == 'true' && env.DOCKERHUB_IMAGE || '' }}
          # latest=false is load-bearing: metadata-action's default (`auto`)
          # would add a bare un-suffixed `:latest` on release builds,
          # clobbering the CUDA preview channel with a ROCm image.
          flavor: |
            latest=false
          tags: |
            type=semver,pattern={{version}},suffix=-rocm,enable=${{ github.event_name == 'release' }}
            type=semver,pattern={{major}}.{{minor}},suffix=-rocm,enable=${{ github.event_name == 'release' && !contains(github.ref, '-') }}
            type=raw,value=beta-rocm,enable=${{ github.event_name == 'release' && github.event.release.prerelease && contains(github.ref, '-beta.') }}
            type=raw,value=stable-rocm,enable=${{ github.event_name == 'release' && !github.event.release.prerelease && !contains(github.ref, '-') }}
            type=raw,value=rocm,enable=${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
            type=raw,value=sha-${{ steps.source.outputs.short_sha }}-rocm

      # cache-from only: reading the CUDA job's exported cache reuses the
      # identical frontend-builder stage. Deliberately NO cache-to — the ROCm
      # runtime layers would overwrite the CUDA job's cache image.
      - name: Build and push (ROCm)
        uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
        with:
          context: .
          file: deploy/Dockerfile
          build-args: |
            BASE_IMAGE=rocm/pytorch:rocm7.2.4_ubuntu24.04_py3.12_pytorch_release_2.8.0
            GPU_FLAVOR=rocm
            BUILD_COMMIT=${{ steps.source.outputs.commit }}
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=registry,ref=${{ env.BUILDCACHE_IMAGE }}:buildkit

      - name: Backfill canonical GHCR release tags (ROCm)
        if: inputs.backfill_release
        shell: bash
        env:
          DOCKERHUB_ENABLED: ${{ steps.dockerhub.outputs.enabled }}
          PROMOTE_STABLE: ${{ inputs.promote_stable }}
          RELEASE_REF: ${{ inputs.release_ref }}
          SOURCE_TAG: sha-${{ steps.source.outputs.short_sha }}-rocm
        run: |
          VERSION="${RELEASE_REF#v}"
          if [[ "$VERSION" == *-beta.* ]]; then
            # A beta never moves :stable-rocm or the floating major.minor tag.
            test "$PROMOTE_STABLE" != "true"
            TAGS=("${VERSION}-rocm" beta-rocm)
          else
            MINOR="${VERSION%.*}"
            TAGS=("${VERSION}-rocm" "${MINOR}-rocm")
            if [[ "$PROMOTE_STABLE" == "true" ]]; then TAGS=(stable-rocm "${TAGS[@]}"); fi
          fi
          for IMAGE in "$IMAGE_NAME" "$LEGACY_IMAGE_NAME"; do
            for TAG in "${TAGS[@]}"; do
              docker buildx imagetools create \
                --tag "${REGISTRY}/${IMAGE}:${TAG}" \
                "${REGISTRY}/${IMAGE}:${SOURCE_TAG}"
            done
          done
          if [[ "$DOCKERHUB_ENABLED" == "true" ]]; then
            for TAG in "${TAGS[@]}"; do
              docker buildx imagetools create \
                --tag "${DOCKERHUB_IMAGE}:${TAG}" \
                "${DOCKERHUB_IMAGE}:${SOURCE_TAG}"
            done
          fi

  # ── arm64 NVIDIA CUDA image variant (#2831) ─────────────────────────────
  # DGX Spark / GB10, GH200 and GB200 (linux/arm64 SBSA). pytorch/pytorch has no
  # arm64 image, so BASE_IMAGE names the Dockerfile's cuda-arm64-base stage
  # (Ubuntu 24.04 + torch 2.8.0+cu129, whose aarch64 wheel bundles CUDA). Built
  # natively on GitHub's arm64 runner, never under QEMU. A SEPARATE job with
  # its own `-cuda-arm64` tags, so the amd64 CUDA tags stay single-arch and an
  # arm64 failure can never block or alter them.
  build-and-push-cuda-arm64:
    # Only version releases publish images; other release tags are ignored.
    if: github.event_name != 'release' || startsWith(github.event.release.tag_name, 'v')
    runs-on: ubuntu-24.04-arm
    steps:
      - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
        with:
          ref: ${{ inputs.backfill_release && inputs.release_ref || github.sha }}

      - name: Validate release backfill ref
        if: inputs.backfill_release
        shell: bash
        env:
          RELEASE_REF: ${{ inputs.release_ref }}
        run: |
          [[ "$RELEASE_REF" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-beta\.[0-9]+)?$ ]]
          test "$(git rev-parse HEAD)" = "$(git rev-parse "refs/tags/${RELEASE_REF}^{commit}")"
          TAG_VERSION="${RELEASE_REF#v}"
          PACKAGE_VERSION="$(python3 -c 'import json; print(json.load(open("package.json"))["version"])')"
          if [[ "$(printf '%s\n' "$TAG_VERSION" '0.5.6' | sort -V | tail -n1)" == '0.5.6' ]]; then
            test "$PACKAGE_VERSION" = '1.0.0'
          else
            test "$PACKAGE_VERSION" = "$TAG_VERSION"
          fi

      - name: Resolve image source commit
        id: source
        shell: bash
        run: |
          echo "short_sha=$(git rev-parse --short=7 HEAD)" >> "$GITHUB_OUTPUT"
          echo "commit=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"

      # The aarch64 torch wheel alone is 3.4 GB. Same reclaim list as the
      # other jobs (paths absent on the arm64 image are simply skipped).
      - name: Free runner disk space
        run: |
          sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \
            /usr/local/.ghcup /opt/hostedtoolcache /usr/share/swift \
            /usr/local/share/boost /usr/local/lib/node_modules
          sudo docker image prune --all --force
          sudo apt-get clean
          df -h /

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0

      - name: Log in to GHCR
        uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      # Same Docker Hub gating as the other jobs.
      - name: Check Docker Hub credentials
        id: dockerhub
        run: echo "enabled=${{ secrets.DOCKERHUB_TOKEN != '' }}" >> "$GITHUB_OUTPUT"

      - name: Log in to Docker Hub
        if: steps.dockerhub.outputs.enabled == 'true'
        uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      # Mirrors the CUDA job's tag rules (incl. the workflow_dispatch and
      # prerelease gating) with a `-cuda-arm64` suffix on every tag.
      - name: Extract metadata (tags, labels)
        id: meta
        uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
        with:
          images: |
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
            ${{ env.REGISTRY }}/${{ env.LEGACY_IMAGE_NAME }}
            ${{ steps.dockerhub.outputs.enabled == 'true' && env.DOCKERHUB_IMAGE || '' }}
          # latest=false is load-bearing: `auto` would add a bare `:latest` on
          # release builds, replacing the amd64 preview with an arm64 image.
          flavor: |
            latest=false
          tags: |
            type=semver,pattern={{version}},suffix=-cuda-arm64,enable=${{ github.event_name == 'release' }}
            type=semver,pattern={{major}}.{{minor}},suffix=-cuda-arm64,enable=${{ github.event_name == 'release' && !contains(github.ref, '-') }}
            type=raw,value=beta-cuda-arm64,enable=${{ github.event_name == 'release' && github.event.release.prerelease && contains(github.ref, '-beta.') }}
            type=raw,value=stable-cuda-arm64,enable=${{ github.event_name == 'release' && !github.event.release.prerelease && !contains(github.ref, '-') }}
            type=raw,value=cuda-arm64,enable=${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
            type=raw,value=sha-${{ steps.source.outputs.short_sha }}-cuda-arm64

      # Its own cache tag: amd64 layers are useless on arm64, and sharing the
      # CUDA job's tag would overwrite it.
      - name: Build and push (CUDA arm64)
        uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
        with:
          context: .
          file: deploy/Dockerfile
          platforms: linux/arm64
          build-args: |
            BASE_IMAGE=cuda-arm64-base
            GPU_FLAVOR=cuda
            BUILD_COMMIT=${{ steps.source.outputs.commit }}
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=registry,ref=${{ env.BUILDCACHE_IMAGE }}:buildkit-cuda-arm64
          # A failed cache export must never fail (or skip) the image publish.
          cache-to: type=registry,ref=${{ env.BUILDCACHE_IMAGE }}:buildkit-cuda-arm64,mode=max,ignore-error=true

      - name: Backfill canonical GHCR release tags (CUDA arm64)
        if: inputs.backfill_release
        shell: bash
        env:
          DOCKERHUB_ENABLED: ${{ steps.dockerhub.outputs.enabled }}
          PROMOTE_STABLE: ${{ inputs.promote_stable }}
          RELEASE_REF: ${{ inputs.release_ref }}
          SOURCE_TAG: sha-${{ steps.source.outputs.short_sha }}-cuda-arm64
        run: |
          VERSION="${RELEASE_REF#v}"
          if [[ "$VERSION" == *-beta.* ]]; then
            # A beta never moves :stable-cuda-arm64 or the floating major.minor tag.
            test "$PROMOTE_STABLE" != "true"
            TAGS=("${VERSION}-cuda-arm64" beta-cuda-arm64)
          else
            MINOR="${VERSION%.*}"
            TAGS=("${VERSION}-cuda-arm64" "${MINOR}-cuda-arm64")
            if [[ "$PROMOTE_STABLE" == "true" ]]; then TAGS=(stable-cuda-arm64 "${TAGS[@]}"); fi
          fi
          for IMAGE in "$IMAGE_NAME" "$LEGACY_IMAGE_NAME"; do
            for TAG in "${TAGS[@]}"; do
              docker buildx imagetools create \
                --tag "${REGISTRY}/${IMAGE}:${TAG}" \
                "${REGISTRY}/${IMAGE}:${SOURCE_TAG}"
            done
          done
          if [[ "$DOCKERHUB_ENABLED" == "true" ]]; then
            for TAG in "${TAGS[@]}"; do
              docker buildx imagetools create \
                --tag "${DOCKERHUB_IMAGE}:${TAG}" \
                "${DOCKERHUB_IMAGE}:${SOURCE_TAG}"
            done
          fi