# Workflow that builds, tests and then pushes the OpenHands and runtime docker images to the ghcr.io repository
name: Docker

# Always run on "main"
# Always run on tags
# Always run on PRs
# Can also be triggered manually
on:
  push:
    branches:
      - main
    tags:
      - "*"
  pull_request:
  workflow_dispatch:
    inputs:
      reason:
        description: "Reason for manual trigger"
        required: true
        default: ""

# If triggered by a PR, it will be in the same group. However, each commit on main will be in its own unique group
concurrency:
  group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
  cancel-in-progress: true

env:
  RELEVANT_SHA: ${{ github.event.pull_request.head.sha || github.sha }}

jobs:
  define-matrix:
    runs-on: blacksmith
    outputs:
      base_image: ${{ steps.define-base-images.outputs.base_image }}
    steps:
      - name: Define base images
        shell: bash
        id: define-base-images
        run: |
          if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
            json=$(jq -n -c '[
                { image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" },
                { image: "ubuntu:24.04", tag: "ubuntu" }
              ]')
          else
            json=$(jq -n -c '[
                { image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" },
                { image: "ubuntu:24.04", tag: "ubuntu" }
              ]')
          fi
          echo "base_image=$json" >> "$GITHUB_OUTPUT"

  # Builds the OpenHands Docker images
  ghcr_build_app:
    name: Build App Image
    runs-on: blacksmith-4vcpu-ubuntu-2204
    if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
    permissions:
      contents: read
      packages: write
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3.6.0
        with:
          image: tonistiigi/binfmt:latest
      - name: Login to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.repository_owner }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - name: Set up Docker Buildx
        id: buildx
        uses: docker/setup-buildx-action@v3
      - name: Lowercase Repository Owner
        run: |
          echo REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
      - name: Build and push app image
        if: "!github.event.pull_request.head.repo.fork"
        run: |
          ./containers/build.sh -i openhands -o ${{ env.REPO_OWNER }} --push

  # Builds the runtime Docker images
  ghcr_build_runtime:
    name: Build Runtime Image
    runs-on: blacksmith-8vcpu-ubuntu-2204
    if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
    permissions:
      contents: read
      packages: write
    needs: define-matrix
    strategy:
      matrix:
        base_image: ${{ fromJson(needs.define-matrix.outputs.base_image) }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3.6.0
        with:
          image: tonistiigi/binfmt:latest
      - name: Login to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.repository_owner }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - name: Set up Docker Buildx
        id: buildx
        uses: docker/setup-buildx-action@v3
      - name: Install poetry via pipx
        run: pipx install poetry
      - name: Set up Python
        uses: useblacksmith/setup-python@v6
        with:
          python-version: "3.12"
          cache: poetry
      - name: Install Python dependencies using Poetry
        run: make install-python-dependencies POETRY_GROUP=main INSTALL_PLAYWRIGHT=0
      - name: Create source distribution and Dockerfile
        run: poetry run python3 -m openhands.runtime.utils.runtime_build --base_image ${{ matrix.base_image.image }} --build_folder containers/runtime --force_rebuild
      - name: Lowercase Repository Owner
        run: |
          echo REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
      - name: Short SHA
        run: |
          echo SHORT_SHA=$(git rev-parse --short "$RELEVANT_SHA") >> $GITHUB_ENV
      - name: Determine docker build params
        if: github.event.pull_request.head.repo.fork != true
        shell: bash
        run: |

          ./containers/build.sh -i runtime -o ${{ env.REPO_OWNER }} -t ${{ matrix.base_image.tag }} --dry

          DOCKER_BUILD_JSON=$(jq -c . < docker-build-dry.json)
          echo "DOCKER_TAGS=$(echo "$DOCKER_BUILD_JSON" | jq -r '.tags | join(",")')" >> $GITHUB_ENV
          echo "DOCKER_PLATFORM=$(echo "$DOCKER_BUILD_JSON" | jq -r '.platform')" >> $GITHUB_ENV
          echo "DOCKER_BUILD_ARGS=$(echo "$DOCKER_BUILD_JSON" | jq -r '.build_args | join(",")')" >> $GITHUB_ENV
      - name: Build and push runtime image ${{ matrix.base_image.image }}
        if: github.event.pull_request.head.repo.fork != true
        uses: useblacksmith/build-push-action@v1
        with:
          push: true
          tags: ${{ env.DOCKER_TAGS }}
          platforms: ${{ env.DOCKER_PLATFORM }}
          build-args: ${{ env.DOCKER_BUILD_ARGS }}
          context: containers/runtime
          provenance: false
      # Forked repos can't push to GHCR, so we just build in order to populate the cache for rebuilding
      - name: Build runtime image ${{ matrix.base_image.image }} for fork
        if: github.event.pull_request.head.repo.fork
        uses: useblacksmith/build-push-action@v1
        with:
          tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
          context: containers/runtime
      - name: Upload runtime source for fork
        if: github.event.pull_request.head.repo.fork
        uses: actions/upload-artifact@v4
        with:
          name: runtime-src-${{ matrix.base_image.tag }}
          path: containers/runtime

  ghcr_build_enterprise:
    name: Push Enterprise Image
    runs-on: blacksmith-8vcpu-ubuntu-2204
    permissions:
      contents: read
      packages: write
    needs: [define-matrix, ghcr_build_app]
    # Do not build enterprise in forks
    if: github.event.pull_request.head.repo.fork != true
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}

      # Set up Docker Buildx for better performance
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
        with:
          driver-opts: network=host

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

      - name: Extract metadata (tags, labels) for Docker
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/openhands/enterprise-server
          tags: |
            type=ref,event=branch
            type=ref,event=pr
            type=sha
            type=sha,format=long
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=semver,pattern={{major}}
          flavor: |
            latest=auto
            prefix=
            suffix=
        env:
          DOCKER_METADATA_PR_HEAD_SHA: true
      - name: Determine app image tag
        shell: bash
        run: |
          # Duplicated with build.sh
          sanitized_ref_name=$(echo "$GITHUB_REF_NAME" | sed 's/[^a-zA-Z0-9.-]\+/-/g')
          OPENHANDS_BUILD_VERSION=$sanitized_ref_name
          sanitized_ref_name=$(echo "$sanitized_ref_name" | tr '[:upper:]' '[:lower:]') # lower case is required in tagging
          echo "OPENHANDS_DOCKER_TAG=${sanitized_ref_name}" >> $GITHUB_ENV
      - name: Build and push Docker image
        uses: useblacksmith/build-push-action@v1
        with:
          context: .
          file: enterprise/Dockerfile
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          build-args: |
            OPENHANDS_VERSION=${{ env.OPENHANDS_DOCKER_TAG }}
          platforms: linux/amd64
          # Add build provenance
          provenance: true
          # Add build attestations for better security
          sbom: true

  enterprise-preview:
    name: Enterprise preview
    if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'deploy')
    runs-on: blacksmith-4vcpu-ubuntu-2204
    needs: [ghcr_build_enterprise]
    steps:
      # This should match the version in enterprise-preview.yml
      - name: Trigger remote job
        run: |
          curl --fail-with-body -sS -X POST \
            -H "Authorization: Bearer ${{ secrets.PAT_TOKEN }}" \
            -H "Accept: application/vnd.github+json" \
            -d "{\"ref\": \"main\", \"inputs\": {\"openhandsPrNumber\": \"${{ github.event.pull_request.number }}\", \"deployEnvironment\": \"feature\", \"enterpriseImageTag\": \"pr-${{ github.event.pull_request.number }}\" }}" \
            https://api.github.com/repos/OpenHands/deploy/actions/workflows/deploy.yaml/dispatches

  # Run unit tests with the Docker runtime Docker images as root
  test_runtime_root:
    name: RT Unit Tests (Root)
    needs: [ghcr_build_runtime, define-matrix]
    runs-on: blacksmith-4vcpu-ubuntu-2404
    strategy:
      fail-fast: false
      matrix:
        base_image: ${{ fromJson(needs.define-matrix.outputs.base_image) }}
    steps:
      - uses: actions/checkout@v4
      - name: Set up Docker Buildx
        id: buildx
        uses: docker/setup-buildx-action@v3
      - name: Download runtime source for fork
        if: github.event.pull_request.head.repo.fork
        uses: actions/download-artifact@v4
        with:
          name: runtime-src-${{ matrix.base_image.tag }}
          path: containers/runtime
      - name: Lowercase Repository Owner
        run: |
          echo REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
      # Forked repos can't push to GHCR, so we need to rebuild using cache
      - name: Build runtime image ${{ matrix.base_image.image }} for fork
        if: github.event.pull_request.head.repo.fork
        uses: useblacksmith/build-push-action@v1
        with:
          load: true
          tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
          context: containers/runtime
      - name: Install poetry via pipx
        run: pipx install poetry
      - name: Set up Python
        uses: useblacksmith/setup-python@v6
        with:
          python-version: "3.12"
          cache: poetry
      - name: Install Python dependencies using Poetry
        run: make install-python-dependencies INSTALL_PLAYWRIGHT=0
      - name: Run docker runtime tests
        shell: bash
        run: |
          # We install pytest-xdist in order to run tests across CPUs
          poetry run pip install pytest-xdist

          # Install to be able to retry on failures for flakey tests
          poetry run pip install pytest-rerunfailures

          image_name=ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}

          # Setting RUN_AS_OPENHANDS to false means use root.
          # That should mean SANDBOX_USER_ID is ignored but some tests do not check for RUN_AS_OPENHANDS.

          TEST_RUNTIME=docker \
          SANDBOX_USER_ID=$(id -u) \
          SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
          TEST_IN_CI=true \
          RUN_AS_OPENHANDS=false \
          poetry run pytest -n 5 -raRs --reruns 2 --reruns-delay 3 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
        env:
          DEBUG: "1"

  # Run unit tests with the Docker runtime Docker images as openhands user
  test_runtime_oh:
    name: RT Unit Tests (openhands)
    runs-on: blacksmith-4vcpu-ubuntu-2404
    needs: [ghcr_build_runtime, define-matrix]
    strategy:
      matrix:
        base_image: ${{ fromJson(needs.define-matrix.outputs.base_image) }}
    steps:
      - uses: actions/checkout@v4
      - name: Set up Docker Buildx
        id: buildx
        uses: docker/setup-buildx-action@v3
      - name: Download runtime source for fork
        if: github.event.pull_request.head.repo.fork
        uses: actions/download-artifact@v4
        with:
          name: runtime-src-${{ matrix.base_image.tag }}
          path: containers/runtime
      - name: Lowercase Repository Owner
        run: |
          echo REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
      # Forked repos can't push to GHCR, so we need to rebuild using cache
      - name: Build runtime image ${{ matrix.base_image.image }} for fork
        if: github.event.pull_request.head.repo.fork
        uses: useblacksmith/build-push-action@v1
        with:
          load: true
          tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
          context: containers/runtime
      - name: Install poetry via pipx
        run: pipx install poetry
      - name: Set up Python
        uses: useblacksmith/setup-python@v6
        with:
          python-version: "3.12"
          cache: poetry
      - name: Install Python dependencies using Poetry
        run: make install-python-dependencies POETRY_GROUP=main,test,runtime INSTALL_PLAYWRIGHT=0
      - name: Run runtime tests
        shell: bash
        run: |
          # We install pytest-xdist in order to run tests across CPUs
          poetry run pip install pytest-xdist

          # Install to be able to retry on failures for flaky tests
          poetry run pip install pytest-rerunfailures

          image_name=ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}

          TEST_RUNTIME=docker \
          SANDBOX_USER_ID=$(id -u) \
          SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
          TEST_IN_CI=true \
          RUN_AS_OPENHANDS=true \
          poetry run pytest -n 5 -raRs --reruns 2 --reruns-delay 3 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
        env:
          DEBUG: "1"

  # The two following jobs (named identically) are to check whether all the runtime tests have passed as the
  # "All Runtime Tests Passed" is a required job for PRs to merge
  # Due to this bug: https://github.com/actions/runner/issues/2566, we want to create a job that runs when the
  # prerequisites have been cancelled or failed so merging is disallowed, otherwise Github considers "skipped" as "success"
  runtime_tests_check_success:
    name: All Runtime Tests Passed
    if: ${{ !cancelled() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }}
    runs-on: blacksmith-4vcpu-ubuntu-2204
    needs: [test_runtime_root, test_runtime_oh]
    steps:
      - name: All tests passed
        run: echo "All runtime tests have passed successfully!"

  runtime_tests_check_fail:
    name: All Runtime Tests Passed
    if: ${{ cancelled() || contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
    runs-on: blacksmith-4vcpu-ubuntu-2204
    needs: [test_runtime_root, test_runtime_oh]
    steps:
      - name: Some tests failed
        run: |
          echo "Some runtime tests failed or were cancelled"
          exit 1
  update_pr_description:
    name: Update PR Description
    if: github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork && github.actor != 'dependabot[bot]'
    needs: [ghcr_build_runtime]
    runs-on: blacksmith-4vcpu-ubuntu-2204
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Get short SHA
        id: short_sha
        run: echo "SHORT_SHA=$(echo ${{ github.event.pull_request.head.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT

      - name: Update PR Description
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
          REPO: ${{ github.repository }}
          SHORT_SHA: ${{ steps.short_sha.outputs.SHORT_SHA }}
        shell: bash
        run: |
          echo "Updating PR description with Docker and uvx commands"
          bash ${GITHUB_WORKSPACE}/.github/scripts/update_pr_description.sh
