name: sunnypilot prebuilt action

env:
  BUILD_DIR: "/data/openpilot"
  OUTPUT_DIR: ${{ github.workspace }}/output
  CI_DIR: ${{ github.workspace }}/release/ci
  SCONS_CACHE_DIR: ${{ github.workspace }}/release/ci/scons_cache
  PUBLIC_REPO_URL: "https://github.com/sunnypilot/sunnypilot"

  # Branch configurations
  STAGING_SOURCE_BRANCH: 'master'

  # Runtime configuration
  SOURCE_BRANCH: "${{ github.head_ref || github.ref_name }}"

on:
  push:
    branches: [ master, master-dev ]
    tags: [ 'release/*' ]
  pull_request_target:
    types: [ labeled ]
  workflow_dispatch:
    inputs:
      wait_for_tests:
        description: 'Wait for tests to finish'
        required: false
        type: boolean
        default: false

jobs:
  prepare_strategy:
    runs-on: ubuntu-24.04
    if: (!contains(github.event_name, 'pull_request') || (github.event.action == 'labeled' && github.event.label.name == 'prebuilt'))
    outputs:
      environment: ${{ steps.strategy.outputs.environment }}
      new_branch: ${{ steps.strategy.outputs.new_branch }}
      extra_version_identifier: ${{ steps.strategy.outputs.extra_version_identifier }}
      version: ${{ steps.strategy.outputs.version }}
      cancel_publish_in_progress: ${{ steps.strategy.outputs.cancel_publish_in_progress }}
      publish_concurrency_group: ${{ steps.strategy.outputs.publish_concurrency_group }}
      is_stable_branch: ${{ steps.strategy.outputs.is_stable_branch }}
      build: ${{ steps.strategy.outputs.build }}
    steps:
      - uses: actions/checkout@v4
      - name: Extract deploy strategy
        id: strategy
        run: |
          echo '::group::Strategy Extraction'
          BRANCH="${{ github.head_ref || github.ref_name }}"
          echo "Current branch: $BRANCH"

          STRATEGY_JSON='${{ vars.DEPLOY_STRATEGY }}'
          CONFIG=$(echo "$STRATEGY_JSON" | jq -r --arg branch "$BRANCH" '
            .configs[] | select(.branch == $branch)
          ')

          BUILD="$(date '+%Y.%m.%d')-${{ github.run_number }}"
          if [[ -z "$CONFIG" || "$CONFIG" == "null" ]]; then
            echo "No exact strategy match found. Falling back to feature/fork logic."
            IS_FORK="${{ github.event.pull_request.head.repo.fork && 'true' || 'false' }}"
            FORK_SUFFIX=$( [[ "$IS_FORK" == "true" ]] && echo "-fork" || echo "" )
            NEW_BRANCH="${BRANCH}${FORK_SUFFIX}-prebuilt"

            echo "new_branch=$NEW_BRANCH" >> $GITHUB_OUTPUT
            echo "version=$BUILD" >> $GITHUB_OUTPUT
            echo "cancel_publish_in_progress=true" >> $GITHUB_OUTPUT
            echo "publish_concurrency_group=publish-${BRANCH}" >> $GITHUB_OUTPUT
            echo "environment=feature-branch" >> $GITHUB_OUTPUT
            echo "extra_version_identifier=feature-branch" >> $GITHUB_OUTPUT
          else
          echo "Matched config: $CONFIG"
            environment=$(echo "$CONFIG" | jq -r '.environment')
            echo "environment=$environment" >> $GITHUB_OUTPUT
            echo "new_branch=$(echo "$CONFIG" | jq -r '.target_branch')" >> $GITHUB_OUTPUT
            cancel="$(echo "$CONFIG" | jq -r '.cancel_publish_in_progress')";
            echo "cancel_publish_in_progress=$( [ "$cancel" = "null" ] && echo "true" || echo $cancel)" >> $GITHUB_OUTPUT
            echo "publish_concurrency_group=publish-${BRANCH}$( [ "$cancel" = "null" ] || [ "$cancel" = "true" ] || echo "${{ github.sha }}" )" >> $GITHUB_OUTPUT

            is_stable_branch="$(echo "$CONFIG" | jq -r '.stable_branch // false')";
            echo "is_stable_branch=$is_stable_branch" >> $GITHUB_OUTPUT

            stable_version=$(cat sunnypilot/common/version.h | grep SUNNYPILOT_VERSION | sed -e 's/[^0-9|.]//g');
            echo "version=$([ "$is_stable_branch" = "true" ] && echo "$stable_version" || echo "$BUILD")" >> $GITHUB_OUTPUT
            echo "extra_version_identifier=${environment}" >> $GITHUB_OUTPUT
          fi
          echo "build=$BUILD" >> $GITHUB_OUTPUT
          cat $GITHUB_OUTPUT

  validate_tests:
    runs-on: ubuntu-24.04
    needs: [ prepare_strategy ]
    if: ${{
      ((github.event_name == 'workflow_dispatch' && inputs.wait_for_tests) ||
      (github.event_name == 'push' && needs.prepare_strategy.outputs.is_stable_branch == 'true') ||
      contains(github.event_name, 'pull_request') && (github.event.action == 'labeled' && github.event.label.name == 'prebuilt'))
      }}
    steps:
      - uses: actions/checkout@v4
      - name: Wait for Tests
        uses: ./.github/workflows/wait-for-action # Path to where you place the action
        with:
          workflow: tests.yaml # The workflow file to monitor
          github-token: ${{ secrets.GITHUB_TOKEN }}
          should-wait-for-start: ${{ github.event_name == 'push' && 'true' || 'false' }}

  build:
    needs: [ validate_tests, prepare_strategy ]
    concurrency:
      group: build-${{ github.head_ref || github.ref_name }}
      cancel-in-progress: false
    runs-on: [self-hosted, tici]
    outputs:
      new_branch: ${{ needs.prepare_strategy.outputs.new_branch }}
      version: ${{ needs.prepare_strategy.outputs.version }}
      extra_version_identifier: ${{ needs.prepare_strategy.outputs.extra_version_identifier }}
      commit_sha: ${{ github.sha }}
    if: ${{
      (always() && !cancelled() && !failure()) &&
      needs.prepare_strategy.result == 'success' &&
      (needs.validate_tests.result == 'success' || needs.validate_tests.result == 'skipped') &&
      (!contains(github.event_name, 'pull_request') ||
      (github.event.action == 'labeled' && github.event.label.name == 'prebuilt'))
      }}
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: recursive
          ref: ${{ env.SOURCE_BRANCH }}
          repository: ${{ github.event.pull_request.head.repo.fork && github.event.pull_request.head.repo.full_name || github.repository }}
      - run: git lfs pull

      - name: Cache SCons
        uses: actions/cache@v4
        with:
          path: ${{env.SCONS_CACHE_DIR}}
          key: scons-${{ runner.os }}-${{ runner.arch }}-${{ env.SOURCE_BRANCH }}-${{ github.sha }}
          # Note: GitHub Actions enforces cache isolation between different build sources (PR builds, workflow dispatches, etc.)
          #   for security. Only caches from the default branch are shared across all builds. This is by design and cannot be overridden.
          restore-keys: |
            scons-${{ runner.os }}-${{ runner.arch }}-${{ env.SOURCE_BRANCH }}
            scons-${{ runner.os }}-${{ runner.arch }}-${{ env.STAGING_SOURCE_BRANCH }}
            scons-${{ runner.os }}-${{ runner.arch }}

      - name: Set environment variables
        id: set-env
        run: |
          echo "new_branch=${{ needs.prepare_strategy.outputs.new_branch }}" >> $GITHUB_OUTPUT
          echo "version=${{ needs.prepare_strategy.outputs.version }}" >> $GITHUB_OUTPUT
          echo "extra_version_identifier=${{ needs.prepare_strategy.outputs.extra_version_identifier }}" >> $GITHUB_OUTPUT
          echo "commit_sha=${{ github.sha }}" >> $GITHUB_OUTPUT

          # Set up common environment
          source /etc/profile;
          export UV_PROJECT_ENVIRONMENT=${HOME}/venv
          export VIRTUAL_ENV=$UV_PROJECT_ENVIRONMENT
          printenv >> $GITHUB_ENV
          if [[ "${{ runner.debug }}" == "1" ]]; then
            cat $GITHUB_OUTPUT
          fi

      - name: Setup build environment
        run: |
          mkdir -p "${BUILD_DIR}/"
          sudo find $BUILD_DIR/ -mindepth 1 -delete
          echo "Starting build stage..."
          echo "BUILD_DIR: ${BUILD_DIR}"
          echo "CI_DIR: ${CI_DIR}"
          echo "VERSION: ${{ steps.set-env.outputs.version }}"
          echo "UV_PROJECT_ENVIRONMENT: ${UV_PROJECT_ENVIRONMENT}"
          echo "VIRTUAL_ENV: ${VIRTUAL_ENV}"
          echo "-------"
          if [[ "${{ runner.debug }}" == "1" ]]; then
            printenv
          fi
          PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --disable

      - name: Build Main Project
        run: |
          export PYTHONPATH="$BUILD_DIR"
          ./release/release_files.py | sort | uniq | rsync -rRl${RUNNER_DEBUG:+v} --files-from=- . $BUILD_DIR/
          cd $BUILD_DIR
          sed -i '/from .board.jungle import PandaJungle, PandaJungleDFU/s/^/#/' panda/__init__.py
          echo "Building sunnypilot's modeld_v2..."
          scons -j$(nproc) cache_dir=${{env.SCONS_CACHE_DIR}} --minimal sunnypilot/modeld_v2
          echo "Building sunnypilot's locationd..."
          scons -j2 cache_dir=${{env.SCONS_CACHE_DIR}} --minimal sunnypilot/selfdrive/locationd
          echo "Building openpilot's locationd..."
          scons -j$(nproc) cache_dir=${{env.SCONS_CACHE_DIR}} --minimal selfdrive/locationd
          echo "Building rest of sunnypilot"
          scons -j$(nproc) cache_dir=${{env.SCONS_CACHE_DIR}} --minimal
          touch ${BUILD_DIR}/prebuilt
          if [[ "${{ runner.debug }}" == "1" ]]; then
            ls -la ${BUILD_DIR}
          fi

      - name: Prepare Output
        run: |
          sudo rm -rf ${OUTPUT_DIR}
          mkdir -p ${OUTPUT_DIR}
          rsync -am${RUNNER_DEBUG:+v} \
            --exclude='.sconsign.dblite' \
            --exclude='*.a' \
            --exclude='*.o' \
            --exclude='*.os' \
            --exclude='*.pyc' \
            --exclude='moc_*' \
            --exclude='__pycache__' \
            --exclude='Jenkinsfile' \
            --exclude='**/release/' \
            --exclude='**/.github/' \
            --exclude='**/selfdrive/ui/replay/' \
            --exclude='**/__pycache__/' \
            --exclude='${{env.SCONS_CACHE_DIR}}' \
            --exclude='**/.git/' \
            --exclude='**/SConstruct' \
            --exclude='**/SConscript' \
            --exclude='**/.venv/' \
            --exclude='selfdrive/modeld/models/driving_vision.onnx' \
            --exclude='selfdrive/modeld/models/driving_policy.onnx' \
            --exclude='third_party/*x86*' \
            --exclude='third_party/*Darwin*' \
            --delete-excluded \
            --chown=comma:comma \
            ${BUILD_DIR}/ ${OUTPUT_DIR}/

      - name: 'Tar.gz files'
        run: |
          tar czf prebuilt.tar.gz -C ${{ env.OUTPUT_DIR }} .
          ls -la prebuilt.tar.gz

      - name: 'Upload Artifact'
        uses: actions/upload-artifact@v4
        with:
          name: prebuilt
          path: prebuilt.tar.gz

      - name: Re-enable powersave
        if: always()
        run: |
          PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --enable


  publish:
    concurrency:
      # We do a bit of a hack here to avoid canceling the publishing job if a new commit comes in while we're publishing by adding the sha to the group name.
      # This means that if multiple commits come in while we're publishing, they will be queued up and publish one after the other.
      # Otherwise, if a job is waiting to be published due to environment wait time, it would be canceled by a new commit and restart the wait time.
      group: ${{ needs.prepare_strategy.outputs.publish_concurrency_group }}
      cancel-in-progress: ${{ needs.prepare_strategy.outputs.cancel_publish_in_progress == 'true' }}
    if: ${{ (always() && !cancelled() && !failure()) && needs.build.result == 'success' && needs.prepare_strategy.result == 'success' && (!contains(github.event_name, 'pull_request') || (github.event.action == 'labeled' && github.event.label.name == 'prebuilt')) }}
    needs: [ build, prepare_strategy ]
    runs-on: ubuntu-24.04
    environment: ${{ needs.prepare_strategy.outputs.environment }}
    steps:
      - uses: actions/checkout@v4

      - name: Download build artifacts
        uses: actions/download-artifact@v4
        with:
          name: prebuilt

      - name: Untar prebuilt
        run: |
          mkdir -p ${{ env.OUTPUT_DIR }}
          tar xzf prebuilt.tar.gz -C ${{ env.OUTPUT_DIR }}

      - name: Configure Git
        run: |
          git config --global user.email "github-actions[bot]@users.noreply.github.com"
          git config --global user.name "github-actions[bot]"

      - name: Publish to Public Repository
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          echo '${{ toJSON(needs.build.outputs) }}'
          ls -la ${{ env.OUTPUT_DIR }}

          ${{ env.CI_DIR }}/publish.sh \
            "${{ github.workspace }}" \
            "${{ env.OUTPUT_DIR }}" \
            "${{ needs.build.outputs.new_branch }}" \
            "${{ needs.build.outputs.version }}" \
            "https://x-access-token:${{github.token}}@github.com/sunnypilot/sunnypilot.git" \
            "${{ needs.build.outputs.extra_version_identifier }}"

          echo ""
          echo "----   ℹ️ To update the list of branches that auto deploy prebuilts  -----"
          echo ""
          echo "1. Go to: ${{ github.server_url }}/${{ github.repository }}/settings/variables/actions/AUTO_DEPLOY_PREBUILT_BRANCHES"
          echo "2. Current value: ${{ vars.AUTO_DEPLOY_PREBUILT_BRANCHES }}"
          echo "3. Update as needed (JSON array with no spaces)"

      - name: Tag ${{ needs.prepare_strategy.outputs.environment }}
        if: ${{ needs.prepare_strategy.outputs.is_stable_branch == 'true' && (github.event_name != 'push' || !startsWith(github.ref, 'refs/tags/')) }}
        run: |
            TAG="${{ needs.prepare_strategy.outputs.environment }}/${{ needs.prepare_strategy.outputs.version }}/${{ needs.prepare_strategy.outputs.build }}"
            git tag -f -a ${TAG} -m "${{ needs.prepare_strategy.outputs.environment }} @ ${{ needs.prepare_strategy.outputs.version }} of build ${{ needs.build.outputs.build }}."
            git push -f origin ${TAG}

  notify:
    needs:
    - prepare_strategy
    - build
    - publish
    runs-on: ubuntu-24.04
    if: ${{ (always() && !cancelled() && !failure())
      && needs.publish.result == 'success'
      && (!contains(github.event_name, 'pull_request') || (github.event.action == 'labeled' && github.event.label.name == 'prebuilt'))
      && (fromJSON(vars.DEV_FEEDBACK_NOTIFICATION_BRANCHES_V2)[github.head_ref || github.ref_name] != null) }}
    steps:
      - uses: actions/checkout@v4

      - name: Prepare notification message
        id: message
        run: |
          TEMPLATE='${{ vars.DISCOURSE_GENERAL_UPDATE_NOTICE }}'
          export VERSION="${{ needs.prepare_strategy.outputs.version }}"
          export branch_name="${{ env.SOURCE_BRANCH }}"
          export new_branch="${{ needs.prepare_strategy.outputs.new_branch }}"
          export commit_sha="${{ github.sha }}"
          export commit_short_sha="${{ github.sha }}"
          export commit_short_sha="${commit_short_sha:0:7}"
          export extra_version_identifier="${{ needs.prepare_strategy.outputs.extra_version_identifier || github.run_number }}"
          export PUBLIC_REPO_URL="${{ env.PUBLIC_REPO_URL }}"

          MESSAGE=$(cat << 'EOF' | envsubst
          ${{ vars.DISCOURSE_GENERAL_UPDATE_NOTICE }}
          EOF
          )

          {
            echo 'content<<EOFMARKER'
            echo "$MESSAGE"
            echo 'EOFMARKER'
          } >> $GITHUB_OUTPUT
        shell: bash

      - name: Post to Discourse
        uses: ./.github/workflows/post-to-discourse
        with:
          discourse-url: ${{ vars.DISCOURSE_URL }}
          api-key: ${{ secrets.DISCOURSE_API_KEY }}
          api-username: "system"
          topic-id: ${{ fromJSON(vars.DEV_FEEDBACK_NOTIFICATION_BRANCHES_V2)[github.head_ref || github.ref_name].topic_id }}
          message: ${{ steps.message.outputs.content }}

  manage-pr-labels:
    name: Remove prebuilt label
    runs-on: ubuntu-latest
    if: (always() && contains(github.event_name, 'pull_request') && (github.event.action == 'labeled' && github.event.label.name == 'prebuilt'))
    env:
      LABEL: prebuilt
    steps:
      - name: Remove trust-fork-pr label if present
        uses: actions/github-script@v7
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const prNumber = context.payload.pull_request.number;

            await github.rest.issues.removeLabel({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: prNumber,
              name: process.env.LABEL
            });

            console.log(`Removed '${process.env.LABEL}' label from PR #${prNumber}`);
