ci(infra): test and publish one candidate artifact
tracker-policy-pr / check-pr (pull_request) Successful in 20s
test / integration-docker (pull_request) Successful in 22s
lint / lint (push) Failing after 46s
test / build-infra (pull_request) Failing after 49s
test / integration-firecracker (pull_request) Has been skipped
test / coverage (pull_request) Has been skipped
test / unit (pull_request) Successful in 1m28s
test / publish-infra (pull_request) Has been skipped

This commit is contained in:
2026-07-19 22:22:42 +00:00
parent d589c08d9d
commit 701f5bf5e3
7 changed files with 210 additions and 63 deletions
-31
View File
@@ -1,31 +0,0 @@
# Build and publish the Firecracker infra rootfs artifact after tests pass on main.
#
# The test suite builds images locally (BOT_BOTTLE_INFRA_BUILD=local). This
# workflow is the separate publish step: it runs AFTER the test workflow
# completes on main pushes, builds the same rootfs, and uploads it to the
# Gitea generic-package registry so operators can pull it without Docker.
#
# Auth: BOT_BOTTLE_INFRA_ARTIFACT_TOKEN must be a Gitea token with
# write:package on the target owner (set in repo secrets).
name: publish-infra
on:
workflow_run:
workflows: [test]
types: [completed]
branches: [main]
jobs:
publish:
runs-on: [self-hosted, kvm]
# Only publish when the test workflow succeeded — never on a failing main.
if: github.event.workflow_run.conclusion == 'success'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build and publish infra rootfs artifact
env:
BOT_BOTTLE_INFRA_ARTIFACT_TOKEN: ${{ secrets.BOT_BOTTLE_INFRA_ARTIFACT_TOKEN }}
run: python3 -m bot_bottle.backend.firecracker.publish_infra
+57 -9
View File
@@ -40,6 +40,21 @@ on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
build-infra:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build infra candidate from this checkout
run: python3 -m bot_bottle.backend.firecracker.publish_infra --output infra-candidate
- name: Upload infra candidate
uses: actions/upload-artifact@v3
with:
name: infra-candidate
path: infra-candidate/
unit: unit:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -89,9 +104,10 @@ jobs:
# PRs don't execute untrusted code on the privileged runner. # PRs don't execute untrusted code on the privileged runner.
# #
# Runner prerequisites (provision once; see README "Firecracker on Linux"): # Runner prerequisites (provision once; see README "Firecracker on Linux"):
# `firecracker` on PATH, `/dev/kvm` accessible, Docker, cached kernel + # `firecracker` on PATH, `/dev/kvm` accessible, cached kernel +
# static dropbear, and the pool as a persistent systemd unit. # static dropbear, and the pool as a persistent systemd unit.
integration-firecracker: integration-firecracker:
needs: build-infra
runs-on: [self-hosted, kvm] runs-on: [self-hosted, kvm]
if: >- if: >-
github.event_name == 'push' || github.event_name == 'push' ||
@@ -111,6 +127,15 @@ jobs:
# range overlap; it prints the exact `backend setup` fix. # range overlap; it prints the exact `backend setup` fix.
python3 cli.py backend status --backend=firecracker python3 cli.py backend status --backend=firecracker
- name: Download the candidate built from this checkout
uses: actions/download-artifact@v3
with:
name: infra-candidate
path: 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: the integration suite runs on stdlib # No dev-requirements install: the integration suite runs on stdlib
# `unittest` (pylint/pyright are lint.yml's concern, not this job's), # `unittest` (pylint/pyright are lint.yml's concern, not this job's),
# and the self-hosted runner's Nix python env has no `pip` module # and the self-hosted runner's Nix python env has no `pip` module
@@ -118,10 +143,7 @@ jobs:
- name: Run integration tests (firecracker) - name: Run integration tests (firecracker)
env: env:
BOT_BOTTLE_BACKEND: firecracker BOT_BOTTLE_BACKEND: firecracker
# Build images locally from the checked-out source — never pull a BOT_BOTTLE_INFRA_ARTIFACT_DIR: ${{ github.workspace }}/infra-candidate
# pre-published artifact during tests. Publishing happens post-merge
# in publish-infra.yml.
BOT_BOTTLE_INFRA_BUILD: local
run: python3 -m unittest discover -t . -s tests/integration -v run: python3 -m unittest discover -t . -s tests/integration -v
# Combined unit+integration coverage + the diff-coverage gate (the hard # Combined unit+integration coverage + the diff-coverage gate (the hard
@@ -141,10 +163,11 @@ jobs:
# See #414 for the planned follow-up: artifact-based coverage combination # See #414 for the planned follow-up: artifact-based coverage combination
# (run tests once in their respective jobs, combine .coverage files here). # (run tests once in their respective jobs, combine .coverage files here).
# #
# BOT_BOTTLE_INFRA_BUILD=local: build images from source here, never pull a # build-infra creates one candidate from the checkout. This job boots that
# pre-published artifact. Registry publishing runs post-merge in # same candidate after integration-firecracker has exercised it; the main
# publish-infra.yml, gated on tests passing. # push path publishes the identical bytes only after every required job.
coverage: coverage:
needs: [build-infra, integration-firecracker]
timeout-minutes: 15 timeout-minutes: 15
runs-on: [self-hosted, kvm] runs-on: [self-hosted, kvm]
if: >- if: >-
@@ -167,16 +190,41 @@ jobs:
# range overlap; it prints the exact `backend setup` fix. # range overlap; it prints the exact `backend setup` fix.
python3 cli.py backend status --backend=firecracker python3 cli.py backend status --backend=firecracker
- name: Download the candidate already exercised by integration
uses: actions/download-artifact@v3
with:
name: infra-candidate
path: infra-candidate
# No dev-requirements install: `coverage` is already provided by the # No dev-requirements install: `coverage` is already provided by the
# self-hosted runner's Nix python env, and that env has no `pip` # self-hosted runner's Nix python env, and that env has no `pip`
# module to install into anyway. `scripts/coverage.sh` + # module to install into anyway. `scripts/coverage.sh` +
# `diff_coverage.py` need only `coverage` (not pylint/pyright). # `diff_coverage.py` need only `coverage` (not pylint/pyright).
- name: Combined coverage (unit + integration, incl. firecracker) - name: Combined coverage (unit + integration, incl. firecracker)
env: env:
BOT_BOTTLE_INFRA_BUILD: local BOT_BOTTLE_INFRA_ARTIFACT_DIR: ${{ github.workspace }}/infra-candidate
run: PYTHON=python3 bash scripts/coverage.sh critical run: PYTHON=python3 bash scripts/coverage.sh critical
- name: Diff-coverage gate (changed lines >= 90%) - name: Diff-coverage gate (changed lines >= 90%)
run: | run: |
git fetch --no-tags origin main:refs/remotes/origin/main git fetch --no-tags origin main:refs/remotes/origin/main
python3 scripts/diff_coverage.py --base origin/main --min 90 python3 scripts/diff_coverage.py --base origin/main --min 90
publish-infra:
needs: [build-infra, 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 candidate
uses: actions/download-artifact@v3
with:
name: infra-candidate
path: infra-candidate
- name: Publish the tested candidate
env:
BOT_BOTTLE_INFRA_ARTIFACT_TOKEN: ${{ secrets.BOT_BOTTLE_INFRA_ARTIFACT_TOKEN }}
run: python3 -m bot_bottle.backend.firecracker.publish_infra --publish-dir infra-candidate
+1 -1
View File
@@ -90,7 +90,7 @@ BOT_BOTTLE_BACKEND=firecracker ./cli.py start <agent>
> **NixOS:** enable `virtualisation.docker`, ensure the KVM module is loaded (`boot.kernelModules = [ "kvm-intel" ];` or `kvm-amd`), and add your user to the `kvm` and `docker` groups. For the network pool, consume the flake module — `imports = [ inputs.bot-bottle.nixosModules.firecracker-netpool ]; services.bot-bottle-firecracker = { enable = true; owner = "you"; };` — then `nixos-rebuild switch` (imperative nft/TAP rules don't survive a rebuild; channel users can `imports = [ <bot-bottle>/nix/firecracker-netpool.nix ]`). `firecracker` isn't in nixpkgs by default as a user binary — install the release binary (pin the version) and put it on `PATH`. > **NixOS:** enable `virtualisation.docker`, ensure the KVM module is loaded (`boot.kernelModules = [ "kvm-intel" ];` or `kvm-amd`), and add your user to the `kvm` and `docker` groups. For the network pool, consume the flake module — `imports = [ inputs.bot-bottle.nixosModules.firecracker-netpool ]; services.bot-bottle-firecracker = { enable = true; owner = "you"; };` — then `nixos-rebuild switch` (imperative nft/TAP rules don't survive a rebuild; channel users can `imports = [ <bot-bottle>/nix/firecracker-netpool.nix ]`). `firecracker` isn't in nixpkgs by default as a user binary — install the release binary (pin the version) and put it on `PATH`.
> **CI:** the coverage gate (`.gitea/workflows/test.yml` → `coverage` job) runs on a self-hosted runner labelled `kvm`, because the Firecracker backend's VM/SSH orchestration is exercised only by the integration suite, which needs `/dev/kvm` + the provisioned pool (a container runner would skip it and read as uncovered). Provision that runner exactly like a normal Firecracker host — `firecracker` on `PATH`, `/dev/kvm`, Docker, the cached guest kernel + static dropbear, and the pool installed as the persistent systemd unit — then register it with the `kvm` label. The unit/lint jobs still run on `ubuntu-latest`. > **CI:** the coverage gate (`.gitea/workflows/test.yml` → `coverage` job) runs on a self-hosted runner labelled `kvm`, because the Firecracker backend's VM/SSH orchestration is exercised only by the integration suite, which needs `/dev/kvm` + the provisioned pool (a container runner would skip it and read as uncovered). Provision that runner exactly like a normal Firecracker host — `firecracker` on `PATH`, `/dev/kvm`, the cached guest kernel + static dropbear, and the pool installed as the persistent systemd unit — then register it with the `kvm` label. A Docker-capable hosted job builds the candidate once; KVM tests boot those exact bytes, and a successful main run publishes them. The unit/lint jobs still run on `ubuntu-latest`.
```sh ```sh
./cli.py start <agent> # builds the image on first run, drops you into claude ./cli.py start <agent> # builds the image on first run, drops you into claude
@@ -113,6 +113,7 @@ def artifact_url(version: str, filename: str) -> str:
_GZ_NAME = "rootfs.ext4.gz" _GZ_NAME = "rootfs.ext4.gz"
_SHA_NAME = "rootfs.ext4.gz.sha256" _SHA_NAME = "rootfs.ext4.gz.sha256"
_CANDIDATE_DIR_ENV = "BOT_BOTTLE_INFRA_ARTIFACT_DIR"
def _cache_root(version: str) -> Path: def _cache_root(version: str) -> Path:
@@ -162,6 +163,28 @@ def ensure_artifact_gz(version: str) -> Path:
"""The verified, cached `rootfs.ext4.gz` for `version` — downloading it (and """The verified, cached `rootfs.ext4.gz` for `version` — downloading it (and
its `.sha256`) once, then reusing it. Fail-closed on a checksum mismatch: its `.sha256`) once, then reusing it. Fail-closed on a checksum mismatch:
the partial is removed and we die rather than boot an unverified rootfs.""" the partial is removed and we die rather than boot an unverified rootfs."""
candidate_dir = os.environ.get(_CANDIDATE_DIR_ENV, "").strip()
if candidate_dir:
root = Path(candidate_dir)
declared = (root / "version.txt").read_text(encoding="utf-8").strip()
if declared != version:
die(
f"infra candidate version mismatch: expected {version}, "
f"bundle contains {declared or '<empty>'}"
)
gz = root / _GZ_NAME
sha = root / _SHA_NAME
if not gz.is_file() or not sha.is_file():
die(f"infra candidate bundle is incomplete: {root}")
expected = sha.read_text().split()[0].strip().lower()
actual = _sha256_file(gz)
if actual != expected:
die(
f"infra candidate checksum mismatch for {version}:\n"
f" expected {expected}\n actual {actual}"
)
return gz
root = _cache_root(version) root = _cache_root(version)
root.mkdir(parents=True, exist_ok=True) root.mkdir(parents=True, exist_ok=True)
gz = root / _GZ_NAME gz = root / _GZ_NAME
+59 -22
View File
@@ -11,7 +11,8 @@ The `<version>` is `infra_artifact.infra_artifact_version(...)`, the content
hash of the rootfs inputs, so a launch host at the same code checkout resolves hash of the rootfs inputs, so a launch host at the same code checkout resolves
the exact artifact this produced. the exact artifact this produced.
python3 -m bot_bottle.backend.firecracker.publish_infra [--dry-run] [--force] python3 -m bot_bottle.backend.firecracker.publish_infra --output DIR
python3 -m bot_bottle.backend.firecracker.publish_infra --publish-dir DIR
Auth: a token with `write:package` on the target owner, from Auth: a token with `write:package` on the target owner, from
`BOT_BOTTLE_INFRA_ARTIFACT_TOKEN`. `BOT_BOTTLE_INFRA_ARTIFACT_TOKEN`.
@@ -24,7 +25,6 @@ import gzip
import hashlib import hashlib
import shutil import shutil
import sys import sys
import tempfile
import urllib.error import urllib.error
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
@@ -131,36 +131,73 @@ def build_artifact(out_dir: Path) -> tuple[str, Path, Path]:
return version, gz, sha return version, gz, sha
def _publish_bundle(root: Path, token: str) -> str:
version = (root / "version.txt").read_text(encoding="utf-8").strip()
expected = infra_artifact.infra_artifact_version(infra_vm._infra_init())
if version != expected:
raise SystemExit(
f"artifact bundle version {version!r} does not match checkout {expected!r}"
)
gz = root / "rootfs.ext4.gz"
sha = root / "rootfs.ext4.gz.sha256"
if not gz.is_file() or not sha.is_file():
raise SystemExit(f"incomplete artifact bundle: {root}")
expected_sha = sha.read_text().split()[0].strip().lower()
if _sha256(gz) != expected_sha:
raise SystemExit("artifact bundle checksum mismatch")
gz_url = infra_artifact.artifact_url(version, gz.name)
sha_url = infra_artifact.artifact_url(version, sha.name)
about_url = infra_artifact.artifact_url(version, _ABOUT_NAME)
# Publishing is idempotent. If this exact complete artifact is already
# present, a test-only main commit is a no-op. Otherwise clear any partial
# upload left by an interrupted prior attempt and upload the complete set.
try:
with urllib.request.urlopen(infra_artifact._open(sha_url)) as resp:
remote_sha = resp.read().decode("utf-8").split()[0].strip().lower()
except urllib.error.HTTPError as e:
if e.code != 404:
raise SystemExit(f"checking existing artifact failed (HTTP {e.code})")
remote_sha = ""
except urllib.error.URLError as e:
raise SystemExit(f"registry unreachable: {sha_url} ({e.reason})")
if remote_sha == expected_sha:
print(f"infra rootfs {version} already published")
return version
for url in (gz_url, sha_url, about_url):
_delete(url, token)
_put(gz_url, gz, token)
_put(sha_url, sha.read_bytes(), token)
_put(about_url, _ABOUT_TEXT.encode(), token)
return version
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="publish_infra", description="Build + publish the infra rootfs artifact.") prog="publish_infra", description="Build + publish the infra rootfs artifact.")
parser.add_argument("--dry-run", action="store_true", mode = parser.add_mutually_exclusive_group(required=True)
help="build the artifact but do not upload") mode.add_argument("--output", type=Path,
parser.add_argument("--force", action="store_true", help="build a candidate bundle in DIR without publishing")
help="overwrite an already-published artifact of this version") mode.add_argument("--publish-dir", type=Path,
help="publish an already-built and tested candidate bundle")
args = parser.parse_args(argv) args = parser.parse_args(argv)
_, _, token = infra_artifact._config() _, _, token = infra_artifact._config()
if not args.dry_run and not token: if args.publish_dir is not None and not token:
raise SystemExit( raise SystemExit(
"no publish token: set BOT_BOTTLE_INFRA_ARTIFACT_TOKEN to a token " "no publish token: set BOT_BOTTLE_INFRA_ARTIFACT_TOKEN to a token "
"with write:package") "with write:package")
with tempfile.TemporaryDirectory(prefix="bb-publish-infra.") as tmp: if args.output is not None:
version, gz, sha = build_artifact(Path(tmp)) args.output.mkdir(parents=True, exist_ok=True)
gz_url = infra_artifact.artifact_url(version, gz.name) version, _gz, _sha = build_artifact(args.output)
sha_url = infra_artifact.artifact_url(version, sha.name) (args.output / "version.txt").write_text(version + "\n", encoding="utf-8")
about_url = infra_artifact.artifact_url(version, _ABOUT_NAME) print(f"built infra rootfs candidate {version}")
if args.dry_run: return 0
print(f"dry-run: would upload -> {gz_url}")
return 0 version = _publish_bundle(args.publish_dir, token)
if args.force:
_delete(gz_url, token)
_delete(sha_url, token)
_delete(about_url, token)
_put(gz_url, gz, token) # streamed from disk (hundreds of MB)
_put(sha_url, sha.read_bytes(), token) # tiny, in-memory is fine
_put(about_url, _ABOUT_TEXT.encode(), token) # package description
print(f"published infra rootfs {version}") print(f"published infra rootfs {version}")
return 0 return 0
+28
View File
@@ -129,6 +129,34 @@ class TestVersionInputs(unittest.TestCase):
class TestEnsureArtifact(_CacheMixin): class TestEnsureArtifact(_CacheMixin):
def test_uses_verified_ci_candidate_without_network(self) -> None:
version = "deadbeef00000000"
gz = _gz(b"candidate ext4")
with tempfile.TemporaryDirectory() as d:
root = Path(d)
(root / "version.txt").write_text(version + "\n")
(root / "rootfs.ext4.gz").write_bytes(gz)
digest = hashlib.sha256(gz).hexdigest()
(root / "rootfs.ext4.gz.sha256").write_text(
f"{digest} rootfs.ext4.gz\n")
with mock.patch.dict(os.environ, {
"BOT_BOTTLE_INFRA_ARTIFACT_DIR": str(root),
}), mock.patch.object(ia.urllib.request, "urlopen") as net:
path = ia.ensure_artifact_gz(version)
self.assertEqual(root / "rootfs.ext4.gz", path)
net.assert_not_called()
def test_rejects_candidate_for_another_version(self) -> None:
with tempfile.TemporaryDirectory() as d:
root = Path(d)
(root / "version.txt").write_text("wrong\n")
with mock.patch.dict(os.environ, {
"BOT_BOTTLE_INFRA_ARTIFACT_DIR": str(root),
}):
with self.assertRaises(Die) as ctx:
ia.ensure_artifact_gz("expected")
self.assertIn("version mismatch", str(ctx.exception.message))
def test_downloads_verifies_and_caches(self) -> None: def test_downloads_verifies_and_caches(self) -> None:
version = "deadbeef00000000" version = "deadbeef00000000"
gz = _gz(b"fake ext4 bytes") gz = _gz(b"fake ext4 bytes")
+42
View File
@@ -6,8 +6,10 @@ read it into memory. Network is mocked; no Docker, no real build.
from __future__ import annotations from __future__ import annotations
import hashlib
import tempfile import tempfile
import unittest import unittest
import urllib.error
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
from unittest import mock from unittest import mock
@@ -58,5 +60,45 @@ class TestPut(unittest.TestCase):
self.assertEqual(b"abc123 rootfs\n", captured[0].data) self.assertEqual(b"abc123 rootfs\n", captured[0].data)
class TestPublishBundle(unittest.TestCase):
def _bundle(self, root: Path, version: str) -> None:
payload = b"candidate"
(root / "version.txt").write_text(version + "\n")
(root / "rootfs.ext4.gz").write_bytes(payload)
digest = hashlib.sha256(payload).hexdigest()
(root / "rootfs.ext4.gz.sha256").write_text(
f"{digest} rootfs.ext4.gz\n")
def test_existing_identical_artifact_is_success(self) -> None:
with tempfile.TemporaryDirectory() as d:
root = Path(d)
self._bundle(root, "v1")
sha = (root / "rootfs.ext4.gz.sha256").read_bytes()
response = mock.MagicMock()
response.__enter__.return_value.read.return_value = sha
with mock.patch.object(
pub.infra_artifact, "infra_artifact_version", return_value="v1"
), mock.patch.object(
pub.urllib.request, "urlopen", return_value=response
), mock.patch.object(pub, "_put") as put:
self.assertEqual("v1", pub._publish_bundle(root, "token"))
put.assert_not_called()
def test_partial_artifact_is_replaced(self) -> None:
with tempfile.TemporaryDirectory() as d:
root = Path(d)
self._bundle(root, "v1")
missing = urllib.error.HTTPError("u", 404, "missing", {}, None)
with mock.patch.object(
pub.infra_artifact, "infra_artifact_version", return_value="v1"
), mock.patch.object(
pub.urllib.request, "urlopen", side_effect=missing
), mock.patch.object(pub, "_delete") as delete, \
mock.patch.object(pub, "_put") as put:
pub._publish_bundle(root, "token")
self.assertEqual(3, delete.call_count)
self.assertEqual(3, put.call_count)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()