name: Docker Build, Test, and Publish

on:
  # This workflow owns its own triggers. ci.yml does not call it.
  # A reusable-workflow call eeps the caller run in progress for that full time.
  # GitHub refuses  ``gh run rerun`` on a run that is still in progress.
  # Thus one slow advisory job blocked every rerun of the fast required jobs. A separate
  # run reruns and cancels independently.
  #
  # Trusted main pushes resolve the environment-scoped Docker Hub secrets in
  # this same workflow, never across a workflow boundary.
  pull_request:
  push:
    branches: [main]
  release:
    types: [published]

permissions:
  contents: read

# Concurrency: push/release runs are NEVER cancelled so every merge gets
# its own image.  PR runs reuse a PR-scoped group with
# cancel-in-progress: true so rapid pushes to the same PR collapse to
# the latest commit.
concurrency:
  group: docker-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

env:
  IMAGE_NAME: nousresearch/hermes-agent

jobs:
  # Classify the PR's changed files. ci.yml used to gate the docker call on
  # its own ``detect`` outputs; now that this workflow triggers itself, it
  # runs the same composite action. On push and release the classifier fails
  # open (every lane true), so post-merge validation is never weakened.
  detect:
    name: Detect affected areas
    runs-on: ubuntu-latest
    timeout-minutes: 10
    outputs:
      build: ${{ steps.gate.outputs.build }}
    steps:
      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      - name: Detect affected areas
        id: classify
        uses: ./.github/actions/detect-changes
        with:
          github-token: ${{ github.token }}

      - name: Decide whether to build
        id: gate
        env:
          # The docker lane derives from python_prod (not python: the image
          # copies installed code, never tests/, so tests-only PRs skip the
          # build), frontend and docker_meta. classify_changes.py owns the
          # formula so this gate and the nix lane cannot drift apart.
          DOCKER: ${{ steps.classify.outputs.docker }}
        run: |
          set -euo pipefail
          if [ "$DOCKER" = "true" ]; then
            echo "build=true" >> "$GITHUB_OUTPUT"
          else
            echo "build=false" >> "$GITHUB_OUTPUT"
          fi

  # Build and test the image for each architecture. This job runs PR code,
  # so it must remain secret-free. Publishing happens in the separate,
  # protected publish job after these tests pass.
  build:
    needs: [detect]
    if: github.repository == 'NousResearch/hermes-agent' && needs.detect.outputs.build == 'true'
    strategy:
      fail-fast: false
      matrix:
        include:
          - arch: amd64
            runner: ubuntu-latest
            platform: linux/amd64
            cache-from: type=gha,scope=docker-amd64
            cache-to: type=gha,mode=max,scope=docker-amd64
          - arch: arm64
            runner: ubuntu-24.04-arm
            platform: linux/arm64
            cache-from: type=gha,scope=docker-arm64
            cache-to: type=gha,mode=max,scope=docker-arm64

    runs-on: ${{ matrix.runner }}
    timeout-minutes: 45
    steps:
      - name: Checkout code
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      # Retry once on transient Docker Hub / buildkit pull failures
      # (connection reset, auth token timeout, rate limiting). The action
      # generates a unique builder name per invocation so the retry doesn't
      # collide with the failed first attempt. A genuine persistent failure
      # still fails the job — only the first attempt has continue-on-error.
      # Refs: docker/setup-buildx-action#510
      - name: Set up Docker Buildx
        id: buildx
        continue-on-error: true
        uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3

      - name: Set up Docker Buildx (retry)
        if: steps.buildx.outcome == 'failure'
        uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3

      # Build once, load into the local daemon for testing.  Cached
      # per-arch; the push step below reuses every layer from this build.
      - name: Build image (${{ matrix.arch }})
        uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
        with:
          context: .
          file: Dockerfile
          load: true
          platforms: ${{ matrix.platform }}
          tags: ${{ env.IMAGE_NAME }}:test
          build-args: |
            HERMES_GIT_SHA=${{ github.sha }}
          cache-from: ${{ matrix.cache-from }}
          cache-to: ${{ (github.event_name != 'pull_request') && matrix.cache-to || '' }}


      # Run the docker-integration test suite against the freshly-built
      # image already loaded into the local daemon (`:test`).
      #
      # Piggybacking here avoids a second image build: the build step
      # already loaded the image into the daemon under
      # `${IMAGE_NAME}:test`, so we just point ``HERMES_TEST_IMAGE`` at
      # that.  The fixture's ``HERMES_TEST_IMAGE`` branch (see
      # tests/docker/conftest.py:62-63) short-circuits the rebuild.
      #
      # Why this job and not a standalone one: the image is 5GB+; passing
      # it between jobs via ``docker save``/``upload-artifact`` is slower
      # than the build itself.  Reusing the existing daemon state is the
      # cheapest path to coverage on every PR that touches docker code.
      # ---------------------------------------------------------------------
      - name: Install uv (for docker tests)
        uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
        with:
          # Pinned: unpinned setup-uv fetches a 'latest' manifest from
          # raw.githubusercontent.com every job; transient fetch failures
          # fail the job (2026-07-28 incident). Keep in sync with tests.yml.
          version: "0.9.28"

      - name: Set up Python 3.11 (for docker tests)
        uses: ./.github/actions/retry
        with:
          command: uv python install 3.11

      - name: Install Python dependencies (for docker tests)
        # ``dev`` extra pulls in pytest, pytest-asyncio —
        # everything tests/docker/ needs.  We deliberately avoid ``all``
        # here because the docker tests only drive the container via
        # subprocess and don't import hermes_agent's optional deps.
        uses: ./.github/actions/retry
        with:
          command: uv sync --locked --python 3.11 --extra dev

      - name: Run docker integration tests
        env:
          # Skip rebuild; use the image already loaded by the build step.
          HERMES_TEST_IMAGE: ${{ env.IMAGE_NAME }}:test
          # Match the policy in tests.yml :: test job — no accidental
          # real-API calls from inside the harness.
          OPENROUTER_API_KEY: ""
          OPENAI_API_KEY: ""
          NOUS_API_KEY: ""
        run: |
          scripts/run_tests.sh tests/docker/ --file-timeout 600

  # ---------------------------------------------------------------------------
  # Rebuild and push each architecture only after the unprivileged build/test
  # matrix passes. This job is the sole Docker Hub credential boundary.
  # ---------------------------------------------------------------------------
  publish:
    if: github.repository == 'NousResearch/hermes-agent' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release')
    needs: [build]
    environment: container-publish
    strategy:
      fail-fast: false
      matrix:
        include:
          - arch: amd64
            runner: ubuntu-latest
            platform: linux/amd64
            cache-from: type=gha,scope=docker-amd64
            cache-to: type=gha,mode=max,scope=docker-amd64
          - arch: arm64
            runner: ubuntu-24.04-arm
            platform: linux/arm64
            cache-from: type=gha,scope=docker-arm64
            cache-to: type=gha,mode=max,scope=docker-arm64
    runs-on: ${{ matrix.runner }}
    timeout-minutes: 30
    steps:
      - name: Checkout trusted source
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

      # Retry once on transient Docker Hub / buildkit pull failures.
      # See build job for rationale; same pattern.
      - name: Set up Docker Buildx
        id: buildx
        continue-on-error: true
        uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3

      - name: Set up Docker Buildx (retry)
        if: steps.buildx.outcome == 'failure'
        uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3

      - name: Log in to Docker Hub
        uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      # Push by digest only (no tag). The merge job assembles the tagged
      # manifest list after both architecture publishers complete.
      - name: Push ${{ matrix.arch }} by digest
        id: push
        uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
        with:
          context: .
          file: Dockerfile
          platforms: ${{ matrix.platform }}
          labels: |
            org.opencontainers.image.revision=${{ github.sha }}
          build-args: |
            HERMES_GIT_SHA=${{ github.sha }}
          outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
          cache-from: ${{ matrix.cache-from }}
          cache-to: ${{ matrix.cache-to }}

      - name: Export digest
        run: |
          mkdir -p /tmp/digests
          digest="${{ steps.push.outputs.digest }}"
          touch "/tmp/digests/${digest#sha256:}"

      - name: Upload digest artifact
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
        with:
          name: digest-${{ matrix.arch }}
          path: /tmp/digests/*
          if-no-files-found: error
          retention-days: 1

  # ---------------------------------------------------------------------------
  # Stitch both per-arch digests into a single tagged multi-arch manifest.
  # This is a registry-side operation — no building, no layer re-push —
  # so it runs in ~30 seconds.
  #
  # On main pushes: tags both :main and :latest.
  # On releases: tags :<release_tag_name>.
  # ---------------------------------------------------------------------------
  merge:
    if: github.repository == 'NousResearch/hermes-agent' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release')
    runs-on: ubuntu-latest
    needs: [publish]
    timeout-minutes: 10
    environment: container-publish
    steps:
      - name: Download digests
        uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
        with:
          path: /tmp/digests
          pattern: digest-*
          merge-multiple: true

      # Retry once on transient Docker Hub / buildkit pull failures.
      # See build job for rationale; same pattern.
      - name: Set up Docker Buildx
        id: buildx
        continue-on-error: true
        uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3

      - name: Set up Docker Buildx (retry)
        if: steps.buildx.outcome == 'failure'
        uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3

      - name: Log in to Docker Hub
        uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Create manifest list and push
        working-directory: /tmp/digests
        env:
          IMAGE_NAME: ${{ env.IMAGE_NAME }}
          RELEASE_TAG: ${{ github.event.release.tag_name }}
        run: |
          set -euo pipefail
          args=()
          for digest_file in *; do
            args+=("${IMAGE_NAME}@sha256:${digest_file}")
          done
          if [ "${{ github.event_name }}" = "release" ]; then
            tags=(-t "${IMAGE_NAME}:${RELEASE_TAG}")
          else
            tags=(-t "${IMAGE_NAME}:main" -t "${IMAGE_NAME}:latest")
          fi
          # Retry: Docker Hub API + just-pushed digest eventual consistency
          # can transiently fail the create; the operation is idempotent.
          for i in 1 2 3; do
            if docker buildx imagetools create "${tags[@]}" "${args[@]}"; then
              break
            fi
            if [ "$i" = 3 ]; then
              echo "::error::imagetools create failed after 3 attempts"
              exit 1
            fi
            echo "::warning::imagetools create failed (attempt $i); retrying in 20s"
            sleep 20
          done

      - name: Inspect image
        env:
          IMAGE_NAME: ${{ env.IMAGE_NAME }}
          RELEASE_TAG: ${{ github.event.release.tag_name }}
        run: |
          if [ "${{ github.event_name }}" = "release" ]; then
            docker buildx imagetools inspect "${IMAGE_NAME}:${RELEASE_TAG}"
          else
            docker buildx imagetools inspect "${IMAGE_NAME}:main"
          fi
