# PRD 0074: CI artifact-based coverage and local Firecracker candidate flow

- **Status:** Active
- **Author:** Claude
- **Created:** 2026-07-21
- **Issue:** #446

## Summary

Restructure the CI test pipeline to run each test suite exactly once, upload
small `.coverage.*` artifacts, and combine them in a lightweight aggregation
job. Move the infra build onto the KVM runner so the ~194 MB rootfs never
crosses the network for PRs. On main-branch pushes, publish the byte-identical
rootfs that was tested.

## Motivation

The prior pipeline had two redundant costs:

1. **Duplicate artifact transfers.** `build-infra` (ubuntu-latest) built and
   uploaded the ~194 MB rootfs; `integration-firecracker` downloaded it; the
   `coverage` job downloaded it a second time. Combined download overhead: ~83
   seconds per run, plus the ~70-second upload.

2. **Duplicate test execution.** `integration-firecracker` ran the Firecracker
   integration suite; `coverage` ran the entire unit + integration suite again
   on the same KVM runner to collect coverage data. Every line of Firecracker
   code was tested twice per CI run.

## Goals

- Each test suite (unit, integration-docker, integration-firecracker) executes
  exactly once per workflow run.
- PRs incur no large artifact transfers — the rootfs stays on the KVM runner.
- Main-branch pushes publish a byte-for-byte identical rootfs to the one that
  passed the integration tests.
- Concurrent workflow runs cannot cross-publish candidates (naturally enforced
  by Gitea Actions' per-run artifact scoping).
- Failed or cancelled runs block publication (enforced by the `needs:` chain on
  `publish-infra`).

## Non-goals

- Changing test semantics or the coverage policy (ADR 0004).
- Removing the KVM runner guard on `integration-firecracker` and `coverage`.
- Changing how `publish_infra.py` builds or uploads the rootfs.

## Design

### Job graph

```
unit ──────────────────────────────────┐
integration-docker ────────────────────┤──► coverage ──► publish-infra (main only)
integration-firecracker (KVM) ─────────┘
```

### `unit`

Unchanged except: `coverage run` writes `--data-file=.coverage.unit`; the file
is uploaded as the `coverage-unit` artifact.

### `integration-docker`

Adds a `coverage` install step. `coverage run` writes `--data-file=.coverage.docker`;
the file is uploaded as `coverage-docker`.

### `integration-firecracker` (KVM runner)

Replaces the old `stage-firecracker-inputs` → `build-infra` → download chain:

1. Builds the infra candidate locally with
   `BOT_BOTTLE_FC_DROPBEAR=/var/cache/bot-bottle-fc/dropbear`.
2. Boots the candidate and runs integration tests with coverage, writing
   `.coverage.firecracker`.
3. Uploads the small `coverage-firecracker` artifact unconditionally.
4. On main-branch pushes only, uploads the rootfs as `infra-candidate` and the
   dropbear as `firecracker-inputs` so `publish-infra` can verify and publish
   the byte-identical artifact.

### `coverage`

Moves from a KVM runner to `ubuntu-latest`. No tests are re-executed:

1. Downloads `coverage-unit`, `coverage-docker`, and `coverage-firecracker`.
2. Runs `scripts/coverage.sh aggregate critical`, which calls
   `coverage combine` then `coverage report`.
3. Runs the diff-coverage gate (`scripts/diff_coverage.py`).

Coverage files use `relative_files = True` (`.coveragerc`) so they combine
cleanly across runners with different absolute workspace paths.

### `publish-infra`

Depends on all four predecessor jobs (unchanged gate). Downloads `infra-candidate`
and `firecracker-inputs` that were uploaded by `integration-firecracker` on
main — the same byte sequence that passed the integration tests.

### Eliminated jobs

- `stage-firecracker-inputs`: existed only to copy the dropbear to ubuntu-latest
  for `build-infra`. No longer needed.
- `build-infra`: the infra candidate is now built on the KVM runner in
  `integration-firecracker`.

### Script changes

`scripts/coverage.sh` gains an `aggregate` mode (`coverage.sh aggregate [critical]`)
that combines pre-existing `.coverage.*` files instead of re-running tests.
The existing run mode (`coverage.sh [critical]`) is preserved for local dev.
