# 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:
  BASE_IMAGE_FOR_HASH_EQUIVALENCE_TEST: nikolaik/python-nodejs:python3.12-nodejs22
  RELEVANT_SHA: ${{ github.event.pull_request.head.sha || github.sha }}

jobs:
  # Builds the OpenHands Docker images
  ghcr_build_app:
    name: Build App Image
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    outputs:
      hash_from_app_image: ${{ steps.get_hash_in_app_image.outputs.hash_from_app_image }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: Free Disk Space (Ubuntu)
        uses: jlumbroso/free-disk-space@main
        with:
          # this might remove tools that are actually needed,
          # if set to "true" but frees about 6 GB
          tool-cache: true
          # all of these default to true, but feel free to set to
          # "false" if necessary for your workflow
          android: true
          dotnet: true
          haskell: true
          large-packages: true
          docker-images: false
          swap-storage: true
      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3.0.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: Build and push app image
        if: "!github.event.pull_request.head.repo.fork"
        run: |
          ./containers/build.sh -i openhands -o ${{ github.repository_owner }} --push
      - name: Build app image
        if: "github.event.pull_request.head.repo.fork"
        run: |
          ./containers/build.sh -i openhands -o ${{ github.repository_owner }} --load
      - name: Get hash in App Image
        id: get_hash_in_app_image
        run: |
          # Lowercase the repository owner
          export REPO_OWNER=${{ github.repository_owner }}
          REPO_OWNER=$(echo $REPO_OWNER | tr '[:upper:]' '[:lower:]')
          # Run the build script in the app image
          docker run -e SANDBOX_USER_ID=0 -v /var/run/docker.sock:/var/run/docker.sock ghcr.io/${REPO_OWNER}/openhands:${{ env.RELEVANT_SHA }} /bin/bash -c "mkdir -p containers/runtime; python3 openhands/runtime/utils/runtime_build.py --base_image ${{ env.BASE_IMAGE_FOR_HASH_EQUIVALENCE_TEST }} --build_folder containers/runtime --force_rebuild" 2>&1 | tee docker-outputs.txt
          # Get the hash from the build script
          hash_from_app_image=$(cat docker-outputs.txt | grep "Hash for docker build directory" | awk -F "): " '{print $2}' | uniq | head -n1)
          echo "hash_from_app_image=$hash_from_app_image" >> $GITHUB_OUTPUT
          echo "Hash from app image: $hash_from_app_image"

  # Builds the runtime Docker images
  ghcr_build_runtime:
    name: Build Image
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    strategy:
      matrix:
        base_image:
          - image: 'nikolaik/python-nodejs:python3.12-nodejs22'
            tag: nikolaik
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: Free Disk Space (Ubuntu)
        uses: jlumbroso/free-disk-space@main
        with:
          # this might remove tools that are actually needed,
          # if set to "true" but frees about 6 GB
          tool-cache: true
          # all of these default to true, but feel free to set to
          # "false" if necessary for your workflow
          android: true
          dotnet: true
          haskell: true
          large-packages: true
          docker-images: false
          swap-storage: true
      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3.0.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: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - name: Cache Poetry dependencies
        uses: actions/cache@v4
        with:
          path: |
            ~/.cache/pypoetry
            ~/.virtualenvs
          key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }}
          restore-keys: |
            ${{ runner.os }}-poetry-
      - name: Install poetry via pipx
        run: pipx install poetry
      - name: Install Python dependencies using Poetry
        run: make install-python-dependencies
      - name: Create source distribution and Dockerfile
        run: poetry run python3 openhands/runtime/utils/runtime_build.py --base_image ${{ matrix.base_image.image }} --build_folder containers/runtime --force_rebuild
      - name: Build and push runtime image ${{ matrix.base_image.image }}
        if: github.event.pull_request.head.repo.fork != true
        run: |
          ./containers/build.sh -i runtime -o ${{ github.repository_owner }} --push -t ${{ matrix.base_image.tag }}
      # Forked repos can't push to GHCR, so we need to upload the image as an artifact
      - name: Build runtime image ${{ matrix.base_image.image }} for fork
        if: github.event.pull_request.head.repo.fork
        uses: docker/build-push-action@v6
        with:
          tags: ghcr.io/all-hands-ai/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
          outputs: type=docker,dest=/tmp/runtime-${{ matrix.base_image.tag }}.tar
          context: containers/runtime
      - name: Upload runtime image for fork
        if: github.event.pull_request.head.repo.fork
        uses: actions/upload-artifact@v4
        with:
          name: runtime-${{ matrix.base_image.tag }}
          path: /tmp/runtime-${{ matrix.base_image.tag }}.tar

  verify_hash_equivalence_in_runtime_and_app:
    name: Verify Hash Equivalence in Runtime and Docker images
    runs-on: ubuntu-latest
    needs: [ghcr_build_runtime, ghcr_build_app]
    strategy:
      fail-fast: false
      matrix:
        base_image: ['nikolaik']
    steps:
      - uses: actions/checkout@v4
      - name: Cache Poetry dependencies
        uses: actions/cache@v4
        with:
          path: |
            ~/.cache/pypoetry
            ~/.virtualenvs
          key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }}
          restore-keys: |
            ${{ runner.os }}-poetry-
      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - name: Install poetry via pipx
        run: pipx install poetry
      - name: Install Python dependencies using Poetry
        run: make install-python-dependencies
      - name: Get hash in App Image
        run: |
          echo "Hash from app image: ${{ needs.ghcr_build_app.outputs.hash_from_app_image }}"
          echo "hash_from_app_image=${{ needs.ghcr_build_app.outputs.hash_from_app_image }}" >> $GITHUB_ENV

      - name: Get hash using code (development mode)
        run: |
          mkdir -p containers/runtime
          poetry run python3 openhands/runtime/utils/runtime_build.py --base_image ${{ env.BASE_IMAGE_FOR_HASH_EQUIVALENCE_TEST }} --build_folder containers/runtime --force_rebuild > output.txt 2>&1
          hash_from_code=$(cat output.txt | grep "Hash for docker build directory" | awk -F "): " '{print $2}' | uniq | head -n1)
          echo "hash_from_code=$hash_from_code" >> $GITHUB_ENV

      - name: Compare hashes
        run: |
          echo "Hash from App Image: ${{ env.hash_from_app_image }}"
          echo "Hash from Code: ${{ env.hash_from_code }}"
          if [ "${{ env.hash_from_app_image }}" = "${{ env.hash_from_code }}" ]; then
            echo "Hashes match!"
          else
            echo "Hashes do not match!"
            exit 1
          fi

  # Run unit tests with the EventStream runtime Docker images as root
  test_runtime_root:
    name: RT Unit Tests (Root)
    needs: [ghcr_build_runtime]
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        base_image: ['nikolaik']
    steps:
      - uses: actions/checkout@v4
      - name: Free Disk Space (Ubuntu)
        uses: jlumbroso/free-disk-space@main
        with:
          # this might remove tools that are actually needed,
          # if set to "true" but frees about 6 GB
          tool-cache: true
          # all of these default to true, but feel free to set to
          # "false" if necessary for your workflow
          android: true
          dotnet: true
          haskell: true
          large-packages: true
          docker-images: false
          swap-storage: true
      - name: Set up Docker Buildx
        id: buildx
        uses: docker/setup-buildx-action@v3
      # Forked repos can't push to GHCR, so we need to download the image as an artifact
      - name: Download runtime image for fork
        if: github.event.pull_request.head.repo.fork
        uses: actions/download-artifact@v4
        with:
          name: runtime-${{ matrix.base_image }}
          path: /tmp
      - name: Load runtime image for fork
        if: github.event.pull_request.head.repo.fork
        run: |
          docker load --input /tmp/runtime-${{ matrix.base_image }}.tar
      - name: Cache Poetry dependencies
        uses: actions/cache@v4
        with:
          path: |
            ~/.cache/pypoetry
            ~/.virtualenvs
          key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }}
          restore-keys: |
            ${{ runner.os }}-poetry-
      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - name: Install poetry via pipx
        run: pipx install poetry
      - name: Install Python dependencies using Poetry
        run: make install-python-dependencies
      - name: Run runtime tests
        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/${{ github.repository_owner }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image }}
          image_name=$(echo $image_name | tr '[:upper:]' '[:lower:]')

          TEST_RUNTIME=eventstream \
          SANDBOX_USER_ID=$(id -u) \
          SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
          TEST_IN_CI=true \
          RUN_AS_OPENHANDS=false \
          poetry run pytest -n 3 -raRs --reruns 2 --reruns-delay 5 --cov=openhands --cov-report=xml -s ./tests/runtime
      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v4
        env:
          CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

  # Run unit tests with the EventStream runtime Docker images as openhands user
  test_runtime_oh:
    name: RT Unit Tests (openhands)
    runs-on: ubuntu-latest
    needs: [ghcr_build_runtime]
    strategy:
      matrix:
        base_image: ['nikolaik']
    steps:
      - uses: actions/checkout@v4
      - name: Free Disk Space (Ubuntu)
        uses: jlumbroso/free-disk-space@main
        with:
          # this might remove tools that are actually needed,
          # if set to "true" but frees about 6 GB
          tool-cache: true
          # all of these default to true, but feel free to set to
          # "false" if necessary for your workflow
          android: true
          dotnet: true
          haskell: true
          large-packages: true
          docker-images: false
          swap-storage: true
      - name: Set up Docker Buildx
        id: buildx
        uses: docker/setup-buildx-action@v3
      # Forked repos can't push to GHCR, so we need to download the image as an artifact
      - name: Download runtime image for fork
        if: github.event.pull_request.head.repo.fork
        uses: actions/download-artifact@v4
        with:
          name: runtime-${{ matrix.base_image }}
          path: /tmp
      - name: Load runtime image for fork
        if: github.event.pull_request.head.repo.fork
        run: |
          docker load --input /tmp/runtime-${{ matrix.base_image }}.tar
      - name: Cache Poetry dependencies
        uses: actions/cache@v4
        with:
          path: |
            ~/.cache/pypoetry
            ~/.virtualenvs
          key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }}
          restore-keys: |
            ${{ runner.os }}-poetry-
      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - name: Install poetry via pipx
        run: pipx install poetry
      - name: Install Python dependencies using Poetry
        run: make install-python-dependencies
      - name: Run runtime tests
        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/${{ github.repository_owner }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image }}
          image_name=$(echo $image_name | tr '[:upper:]' '[:lower:]')

          TEST_RUNTIME=eventstream \
          SANDBOX_USER_ID=$(id -u) \
          SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
          TEST_IN_CI=true \
          RUN_AS_OPENHANDS=true \
          poetry run pytest -n 3 -raRs --reruns 2 --reruns-delay 5 --cov=openhands --cov-report=xml -s ./tests/runtime
      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v4
        env:
          CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

  # 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: ubuntu-latest
    needs: [test_runtime_root, test_runtime_oh, verify_hash_equivalence_in_runtime_and_app]
    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: ubuntu-latest
    needs: [test_runtime_root, test_runtime_oh, verify_hash_equivalence_in_runtime_and_app]
    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: ubuntu-latest
    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 }}
        run: |
          echo "updating PR description"
          DOCKER_RUN_COMMAND="docker run -it --rm \
            -p 3000:3000 \
            -v /var/run/docker.sock:/var/run/docker.sock \
            --add-host host.docker.internal:host-gateway \
            -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:$SHORT_SHA-nikolaik \
            --name openhands-app-$SHORT_SHA \
            docker.all-hands.dev/all-hands-ai/openhands:$SHORT_SHA"

          PR_BODY=$(gh pr view $PR_NUMBER --json body --jq .body)

          if echo "$PR_BODY" | grep -q "To run this PR locally, use the following command:"; then
            UPDATED_PR_BODY=$(echo "${PR_BODY}" | sed -E "s|docker run -it --rm.*|$DOCKER_RUN_COMMAND|")
          else
            UPDATED_PR_BODY="${PR_BODY}

          ---

          To run this PR locally, use the following command:
          \`\`\`
          $DOCKER_RUN_COMMAND
          \`\`\`"
          fi

          echo "updated body: $UPDATED_PR_BODY"
          gh pr edit $PR_NUMBER --body "$UPDATED_PR_BODY"
