name: wifi-densepose sensing-server → Docker Hub + ghcr.io

# Build + publish the `wifi-densepose` sensing-server image to both Docker Hub
# (`ruvnet/wifi-densepose`) and ghcr.io (`ghcr.io/ruvnet/wifi-densepose`) on:
#   - push to main affecting the Dockerfile, the server crate, the UI assets,
#     or this workflow itself,
#   - tag push matching v* (release builds),
#   - manual workflow_dispatch.
#
# Closes #520 and #514: the stale `:latest` is rebuilt and pushed automatically
# whenever the surface that produces it changes, and the Dockerfile fails the
# build if the observatory/pose-fusion UI assets ever go missing again.
#
# Secrets:
#   DOCKERHUB_USERNAME — `ruvnet` (Docker Hub login name)
#   DOCKERHUB_TOKEN    — Docker Hub access token with read/write/delete scope
# (ghcr.io uses the workflow's GITHUB_TOKEN — no secret needed.)

on:
  push:
    branches: [main]
    paths:
      - 'docker/Dockerfile.rust'
      - 'docker/docker-entrypoint.sh'
      - 'v2/crates/wifi-densepose-sensing-server/**'
      - 'v2/crates/wifi-densepose-signal/**'
      - 'v2/crates/wifi-densepose-vitals/**'
      - 'v2/crates/wifi-densepose-wifiscan/**'
      - 'v2/crates/wifi-densepose-bfld/**'
      - 'v2/crates/cog-ha-matter/**'
      - 'v2/Cargo.toml'
      - 'v2/Cargo.lock'
      - 'ui/**'
      - '.github/workflows/sensing-server-docker.yml'
    tags: ['v*']
  workflow_dispatch: {}

permissions:
  contents: read
  packages: write

concurrency:
  group: sensing-server-docker-${{ github.ref }}
  cancel-in-progress: true

jobs:
  build-and-publish:
    name: build · push · smoke-test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: recursive

      # QEMU is required so the amd64 GitHub runner can cross-build the
      # linux/arm64 layer below (Dockerfile.rust is arch-agnostic — no `--target`
      # flag — so buildx + QEMU is all that's needed; arm64 builds are emulated
      # by the runner, not built on a separate arm64 host).
      - uses: docker/setup-qemu-action@v3

      - uses: docker/setup-buildx-action@v3

      - name: Log in to Docker Hub
        # Bypassing docker/login-action@v3: the action kept emitting
        # "malformed HTTP Authorization header" against a known-good
        # dckr_pat_* token (verified by direct curl against the Hub API).
        # `docker login --password-stdin` is the documented credential
        # path and avoids whatever encoding step the action injects.
        env:
          DH_USER: ${{ secrets.DOCKERHUB_USERNAME }}
          DH_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
        run: |
          printf '%s' "$DH_TOKEN" | docker login docker.io -u "$DH_USER" --password-stdin

      - name: Log in to ghcr.io
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Compute tags
        id: meta
        uses: docker/metadata-action@v6
        with:
          images: |
            docker.io/ruvnet/wifi-densepose
            ghcr.io/ruvnet/wifi-densepose
          tags: |
            type=ref,event=branch
            type=ref,event=tag
            type=sha,format=short
            type=raw,value=latest,enable={{is_default_branch}}

      - name: Build + push
        id: build
        uses: docker/build-push-action@v7
        with:
          context: .
          file: docker/Dockerfile.rust
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          # README badge advertises `amd64 + arm64`, and #547 promised multi-arch
          # as part of the docker publish refresh; arm64 was never actually wired
          # in, so Apple Silicon Macs hit `no matching manifest for linux/arm64/v8`
          # on `docker pull ruvnet/wifi-densepose:latest` (#136, #625). Build both.
          platforms: linux/amd64,linux/arm64

      # ---------------------------------------------------------------------
      # Smoke-test the freshly-pushed image:
      #   1. UI assets that closed #520 are inside `/app/ui` (the Dockerfile's
      #      RUN guard catches missing ones at build time, this re-checks the
      #      pushed artifact post-hoc as belt-and-braces).
      #   2. /health is up.
      #   3. /api/v1/info returns 200 with no auth (LAN-mode default).
      #   4. With RUVIEW_API_TOKEN set, /api/v1/info returns 401 without a
      #      Bearer header, 200 with the correct one (the #443 auth middleware).
      # ---------------------------------------------------------------------
      - name: Smoke-test image assets + LAN-mode HTTP
        run: |
          set -euo pipefail
          IMAGE="ghcr.io/ruvnet/wifi-densepose:sha-${GITHUB_SHA::7}"
          docker pull "$IMAGE"
          docker run --rm "$IMAGE" sh -c \
            'ls /app/ui/observatory.html /app/ui/pose-fusion.html /app/ui/index.html /app/ui/viz.html >/dev/null'
          docker run --rm "$IMAGE" sh -c 'ls -d /app/ui/observatory /app/ui/pose-fusion >/dev/null'

          docker run -d --name sm -p 3000:3000 -e CSI_SOURCE=simulated "$IMAGE"
          # Wait up to 30 s for /health.
          for _ in $(seq 1 30); do
            if curl -fsS http://127.0.0.1:3000/health >/dev/null 2>&1; then break; fi
            sleep 1
          done
          curl -fsS http://127.0.0.1:3000/health
          curl -fsS http://127.0.0.1:3000/api/v1/info >/dev/null
          curl -fsS http://127.0.0.1:3000/ui/observatory.html >/dev/null
          curl -fsS http://127.0.0.1:3000/ui/pose-fusion.html >/dev/null
          docker stop sm

      - name: Smoke-test the bearer-token auth path
        run: |
          set -euo pipefail
          IMAGE="ghcr.io/ruvnet/wifi-densepose:sha-${GITHUB_SHA::7}"
          docker run -d --name auth \
            -p 3000:3000 \
            -e CSI_SOURCE=simulated \
            -e RUVIEW_API_TOKEN=smoke-test-token-do-not-use \
            "$IMAGE"
          for _ in $(seq 1 30); do
            if curl -fsS http://127.0.0.1:3000/health >/dev/null 2>&1; then break; fi
            sleep 1
          done
          # /health stays unauthenticated.
          curl -fsS http://127.0.0.1:3000/health >/dev/null
          # /api/v1/info without a bearer → 401.
          code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3000/api/v1/info)
          test "$code" = "401" || { echo "expected 401, got $code"; exit 1; }
          # Wrong bearer → 401.
          code=$(curl -s -o /dev/null -w '%{http_code}' -H 'Authorization: Bearer wrong' http://127.0.0.1:3000/api/v1/info)
          test "$code" = "401" || { echo "expected 401 (wrong token), got $code"; exit 1; }
          # Correct bearer → 200.
          curl -fsS -H 'Authorization: Bearer smoke-test-token-do-not-use' http://127.0.0.1:3000/api/v1/info >/dev/null
          docker stop auth

      - name: Summary
        if: always()
        run: |
          {
            echo "## sensing-server image published"
            echo
            echo "Tags:"
            echo '```'
            echo "${{ steps.meta.outputs.tags }}"
            echo '```'
            echo
            echo "Closes #520 (missing observatory/pose-fusion UI assets) and #514 (stale `:latest` for the v0.6+ packet format)."
            echo "The Dockerfile fails the build if those UI assets ever disappear again, and this workflow rebuilds + pushes automatically on every change to the surface."
          } >> "$GITHUB_STEP_SUMMARY"
