name: Unified Tests (Fly Runner) on: push: branches: [main] paths: - 'src/**' - 'tests/**' # Manual trigger for PRs: add the `run-unified-tests` label to run the suite # against the PR's merge commit. The label is purged as soon as the run # starts so it can be re-added to trigger another run. pull_request: types: [labeled] # Cap spend: at most one active run per PR (per ref for push). Re-triggering # a PR run cancels the in-flight one instead of stacking Fly machines; pushes # to main queue instead of cancelling so main CI results aren't lost. The # cleanup-machine job runs `if: always()`, which still executes on cancelled # runs, so a cancelled run's Fly machine and runner are still torn down. concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name != 'push' }} permissions: contents: read actions: read jobs: # Only code owners (.github/CODEOWNERS) may trigger the suite manually via # the label; the gate also purges the trigger label so it can be re-added # to trigger another run. gate: name: Gate manual trigger permissions: contents: read pull-requests: write uses: ./.github/workflows/manual-trigger-gate.yml with: label: run-unified-tests start-runner: name: Start Fly Runner needs: gate # always() lets this run on push events, where the gate's jobs are skipped. # Label adds other than run-unified-tests trigger the workflow but skip # every job here; the run-unified-tests label additionally requires the # gate's CODEOWNERS check to have passed. if: >- always() && (github.event_name == 'push' || (github.event.label.name == 'run-unified-tests' && needs.gate.outputs.authorized == 'true')) uses: ./.github/workflows/start-fly-runner.yml secrets: inherit unified-tests: name: Run Unified Tests runs-on: ${{ fromJSON(format('[{0}]', needs.start-runner.outputs.runner-labels)) }} needs: start-runner if: needs.start-runner.outputs.runner-ready == 'true' timeout-minutes: 90 environment: unified-tests permissions: id-token: write # Required for OIDC authentication with AWS contents: read env: PYTHONUNBUFFERED: "1" TEST_DISCORD_WEBHOOK_URL: ${{ secrets.TEST_DISCORD_WEBHOOK_URL }} steps: - name: Checkout repository uses: actions/checkout@v4 with: ref: ${{ github.sha }} - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: ${{ vars.AWS_OIDC_ROLE_ARN }} aws-region: us-east-1 role-duration-seconds: 43200 # 12 hours # Resolves secret ids from the newest release tags, fetches the newest # available staging secret into the job env, and fails if none loaded. - name: Load staging secrets uses: ./.github/actions/load-staging-secrets with: secret-prefix: ${{ secrets.STAGING_SECRET_PREFIX }} # Layer test-specific overrides on top of the staging secret. The staging # dotenv tracks the deployed release and can drift from what main's config # expects; the TESTING_SECRET_ID secret holds only the keys (flat JSON, # exact env var names) the unified tests need to pin. The get-secrets # action refuses to inject an env var that already exists, so the # overrides are fetched under a prefix alias here and promoted over the # staging values in the next step. - name: Fetch testing secret overrides uses: aws-actions/aws-secretsmanager-get-secrets@v2 with: secret-ids: | HONCHO_TEST_OVERRIDE,${{ secrets.TESTING_SECRET_ID }} parse-json-secrets: true # Re-export each HONCHO_TEST_OVERRIDE_* var under its real name; the # later $GITHUB_ENV write wins over the value loaded from the staging # secret. Values are already masked by the fetch step above. - name: Apply testing secret overrides run: | set -euo pipefail applied=0 while IFS= read -r -d '' entry; do name="${entry%%=*}" value="${entry#*=}" case "$name" in HONCHO_TEST_OVERRIDE_*) target="${name#HONCHO_TEST_OVERRIDE_}" { echo "${target}<<__HONCHO_OVERRIDE_EOF__" printf '%s\n' "$value" echo "__HONCHO_OVERRIDE_EOF__" } >> "$GITHUB_ENV" echo "Overriding ${target}" applied=$((applied + 1)) ;; esac done < <(env -0) echo "Applied ${applied} override(s)" # Configure the test environment. Disables auth/Sentry/CloudEvents telemetry # (their endpoints aren't reachable from CI), and points REASONING_TRACES_FILE # at a shared path so the API + deriver record full LLM I/O for auditing — the # runner uploads it to S3. Written after the fetch steps so these win over the # values loaded from Secrets Manager (last $GITHUB_ENV write wins). Stale # config keys loaded from the staging secret (e.g. settings that have since # been renamed or removed on main) must always be ignored by the app config. - name: Configure test environment run: | { echo "AUTH_USE_AUTH=false" echo "SENTRY_ENABLED=false" echo "TELEMETRY_ENABLED=false" echo "REASONING_TRACES_FILE=unified-reasoning-traces.jsonl" } >> "$GITHUB_ENV" - name: Verify Docker is available run: docker info - name: Verify uv and Python run: | uv --version python3.12 --version which python3.12 - name: Install the project run: uv sync --all-extras - name: Run unified tests run: uv run python -m tests.unified.run cleanup-machine: name: Cleanup Fly Machine and Runner runs-on: ubuntu-latest needs: [start-runner, unified-tests] if: always() && needs.start-runner.outputs.machine-id != '' env: FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN_TESTING }} GITHUB_TOKEN: ${{ secrets.GH_TOKEN_ACTIONS }} FLY_RUNNER_APP: ivysaur steps: - name: Setup Fly CLI uses: superfly/flyctl-actions/setup-flyctl@1.5 - name: Cleanup fly machine run: | set -euo pipefail MACHINE_ID="${{ needs.start-runner.outputs.machine-id }}" if [ -z "$MACHINE_ID" ]; then echo "No machine ID provided, skipping Fly cleanup." exit 0 fi echo "🧹 Cleaning up machine: $MACHINE_ID" flyctl machines stop "$MACHINE_ID" -a "$FLY_RUNNER_APP" || echo "Machine may already be stopped" flyctl machines destroy "$MACHINE_ID" -a "$FLY_RUNNER_APP" --force || echo "Failed to destroy machine" - name: Cleanup GitHub runner run: | set -euo pipefail RUNNER_NAME="${{ needs.start-runner.outputs.runner-name }}" FALLBACK_LABEL="${{ github.run_id }}" echo "🗑️ Cleaning up GitHub runner (name: ${RUNNER_NAME:-unknown}, label: ${FALLBACK_LABEL})" RUNNERS_RESPONSE=$(curl -s \ -H "Authorization: Bearer $GITHUB_TOKEN" \ -H "Accept: application/vnd.github+json" \ "https://api.github.com/repos/${{ github.repository }}/actions/runners") if echo "$RUNNERS_RESPONSE" | grep -q '"message"'; then echo "⚠️ Failed to fetch runners: $(echo "$RUNNERS_RESPONSE" | jq -r '.message')" exit 0 fi RUNNER_ID="" if [ -n "$RUNNER_NAME" ]; then RUNNER_ID=$(echo "$RUNNERS_RESPONSE" | jq -r --arg name "$RUNNER_NAME" '.runners[]? | select(.name == $name) | .id') fi if [ -z "$RUNNER_ID" ]; then RUNNER_ID=$(echo "$RUNNERS_RESPONSE" | jq -r --arg label "$FALLBACK_LABEL" '.runners[]? | select([.labels[].name] | index($label)) | .id' | head -n 1) fi if [ -z "$RUNNER_ID" ] || [ "$RUNNER_ID" = "null" ]; then echo "⚠️ Runner not found, nothing to delete." exit 0 fi DELETE_RESPONSE=$(curl -s -w "%{http_code}" \ -X DELETE \ -H "Accept: application/vnd.github+json" \ -H "Authorization: Bearer $GITHUB_TOKEN" \ "https://api.github.com/repos/${{ github.repository }}/actions/runners/$RUNNER_ID") HTTP_CODE="${DELETE_RESPONSE: -3}" if [ "$HTTP_CODE" = "204" ]; then echo "✅ Successfully deleted runner." else echo "⚠️ Failed to delete runner. HTTP code: $HTTP_CODE" echo "Response: ${DELETE_RESPONSE%???}" fi