# Run the project's test suite on every PR push and on push to main. # # The suite uses stdlib `unittest` discovery — no external Python # dependencies are required to execute it. Tests are split by directory: # # tests/unit/ — pure unit tests; always run # tests/integration/ — need a reachable backend; skip cleanly when # the backend isn't available on the runner # tests/canaries/ — upstream regression canaries; run on a separate # schedule (see canaries.yml), not here # # Each test job runs once under coverage and uploads a small .coverage.* # artifact. The `coverage` job combines them — no test reruns, no KVM # dependency on that job. For main-branch pushes only, the tested rootfs # and matching dropbear are uploaded so `publish-infra` can publish the # byte-identical artifact that was tested. PRs avoid the ~194 MB rootfs # transfer entirely. name: test on: push: branches: - main paths: - '**.py' - '.gitea/workflows/**.yml' - 'scripts/**' - 'README.md' # Dockerfiles and pyproject.toml are baked into the infra rootfs; a # change here alters what the integration/coverage jobs build locally. - 'Dockerfile*' - 'pyproject.toml' pull_request: paths: - '**.py' - '.gitea/workflows/**.yml' - 'scripts/**' - 'README.md' - 'Dockerfile*' - 'pyproject.toml' workflow_dispatch: jobs: unit: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 # No actions/setup-python: the runner image already ships Python 3.12, # and older act_runner engines mishandle setup-python's PATH (coverage # lands in one interpreter, `python3` resolves to another). Install # straight into the ephemeral job container's system Python — # --break-system-packages is safe because the container is disposable. - name: Install dev requirements run: python3 -m pip install --break-system-packages -r requirements-dev.txt - name: Run unit tests with coverage run: python3 -m coverage run --data-file=.coverage.unit -m unittest discover -t . -s tests/unit -v - name: Report unit coverage run: python3 -m coverage report --data-file=.coverage.unit -m - name: Upload unit coverage artifact uses: actions/upload-artifact@v3 with: name: coverage-unit path: .coverage.unit integration-docker: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 # No actions/setup-python (see the note in the `unit` job); the # container's system Python 3.12 runs the stdlib test suite directly. - name: Install coverage run: python3 -m pip install --break-system-packages coverage - name: Show environment run: | python3 --version if command -v docker >/dev/null 2>&1; then docker version || true else echo "docker not on PATH — integration tests will skip" fi - name: Run integration tests (docker) with coverage env: BOT_BOTTLE_BACKEND: docker run: python3 -m coverage run --data-file=.coverage.docker -m unittest discover -t . -s tests/integration -v - name: Upload docker coverage artifact uses: actions/upload-artifact@v3 with: name: coverage-docker path: .coverage.docker # Integration tests against the Firecracker backend. Runs on a self-hosted # KVM runner (label `kvm`) where /dev/kvm and the TAP/nft pool are available. # # Restricted to same-repo PRs, push to main, and workflow_dispatch — fork # PRs don't execute untrusted code on the privileged runner. # # Runner prerequisites (provision once; see README "Firecracker on Linux"): # `firecracker` on PATH, `/dev/kvm` accessible, cached kernel + # static dropbear at /var/cache/bot-bottle-fc/dropbear, and the pool as a # persistent systemd unit. # # The infra candidate is built here directly (no artifact download) to # eliminate the ~70 s ubuntu-latest upload + ~83 s combined download that # the old build-infra → integration-firecracker + coverage chain incurred. # For main-branch pushes the tested rootfs and matching dropbear are # uploaded so publish-infra can publish the byte-identical artifact; PRs # skip those uploads entirely. integration-firecracker: runs-on: [self-hosted, kvm] if: >- github.event_name == 'push' || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) steps: - name: Checkout uses: actions/checkout@v4 - name: Preflight — Firecracker host is ready run: | command -v firecracker >/dev/null || { echo "firecracker not on PATH — provision the runner (README: Firecracker on Linux)"; exit 1; } test -e /dev/kvm || { echo "/dev/kvm missing — KVM not available on this runner"; exit 1; } # `backend status` exits non-zero unless the TAP pool is up + no # range overlap; it prints the exact `backend setup` fix. python3 cli.py backend status --backend=firecracker - name: Build infra candidate from this checkout env: BOT_BOTTLE_FC_DROPBEAR: /var/cache/bot-bottle-fc/dropbear run: python3 -m bot_bottle.backend.firecracker.publish_infra --output infra-candidate - name: Replace the persistent infra VM with the candidate run: python3 -c 'from bot_bottle.backend.firecracker import infra_vm; infra_vm.stop()' # No dev-requirements install: `coverage` is already provided by the # self-hosted runner's Nix python env, and that env has no `pip` # module to install into anyway. - name: Run integration tests (firecracker) with coverage env: BOT_BOTTLE_BACKEND: firecracker BOT_BOTTLE_INFRA_ARTIFACT_DIR: ${{ github.workspace }}/infra-candidate run: python3 -m coverage run --data-file=.coverage.firecracker -m unittest discover -t . -s tests/integration -v - name: Upload firecracker coverage artifact uses: actions/upload-artifact@v3 with: name: coverage-firecracker path: .coverage.firecracker # Only upload the large rootfs artifact on main-branch pushes; # PRs avoid the ~194 MB transfer. publish-infra only runs on main # and downloads these to publish the byte-identical tested rootfs. - name: Upload tested rootfs (main branch only) if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: actions/upload-artifact@v3 with: name: infra-candidate path: infra-candidate/ - name: Upload dropbear for publish verification (main branch only) if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: actions/upload-artifact@v3 with: name: firecracker-inputs path: /var/cache/bot-bottle-fc/dropbear # Combined coverage gate: aggregates .coverage.* artifacts uploaded by each # test job, then runs the diff-coverage gate (new/changed lines >= 90%). # # Runs on ubuntu-latest — no KVM needed, no test reruns. Coverage files use # relative_files = True (.coveragerc) so they combine cleanly across runners. # # Restricted to the same events as integration-firecracker: it depends on # that job's coverage artifact and skips for fork PRs alongside it. coverage: needs: [unit, integration-docker, integration-firecracker] timeout-minutes: 15 runs-on: ubuntu-latest if: >- github.event_name == 'push' || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Install coverage run: python3 -m pip install --break-system-packages coverage - name: Download unit coverage artifact uses: actions/download-artifact@v3 with: name: coverage-unit path: . - name: Download docker coverage artifact uses: actions/download-artifact@v3 with: name: coverage-docker path: . - name: Download firecracker coverage artifact uses: actions/download-artifact@v3 with: name: coverage-firecracker path: . - name: Combined coverage (unit + integration, incl. firecracker) run: PYTHON=python3 bash scripts/coverage.sh aggregate critical - name: Diff-coverage gate (changed lines >= 90%) run: | git fetch --no-tags origin main:refs/remotes/origin/main python3 scripts/diff_coverage.py --base origin/main --min 90 publish-infra: needs: [unit, integration-docker, integration-firecracker, coverage] runs-on: ubuntu-latest if: github.event_name == 'push' && github.ref == 'refs/heads/main' steps: - name: Checkout the tested revision uses: actions/checkout@v4 - name: Download the tested rootfs uses: actions/download-artifact@v3 with: name: infra-candidate path: infra-candidate # publish_infra re-derives the version from the checkout to confirm the # bundle matches before uploading, and the version hashes the dropbear # bytes. Download the SAME dropbear integration-firecracker used, or # the recheck computes a ""-dropbear version and rejects the # candidate. - name: Download the staged dropbear (matches build's version) uses: actions/download-artifact@v3 with: name: firecracker-inputs path: firecracker-inputs - name: Publish the tested candidate env: BOT_BOTTLE_INFRA_ARTIFACT_TOKEN: ${{ secrets.BOT_BOTTLE_INFRA_ARTIFACT_TOKEN }} BOT_BOTTLE_FC_DROPBEAR: ${{ github.workspace }}/firecracker-inputs/dropbear run: python3 -m bot_bottle.backend.firecracker.publish_infra --publish-dir infra-candidate