From fa11ad9a4aada7e087f48a96361419c645a0559b Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 20 Jul 2026 02:07:08 -0400 Subject: [PATCH] fix(ci,infra): repair post-merge publish + harden candidate handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups for the infra candidate-artifact flow: 1. publish-infra would fail on the first merge to main. The rootfs version now hashes the dropbear bytes, and build-infra sets BOT_BOTTLE_FC_DROPBEAR to the staged dropbear — but publish-infra (ubuntu-latest) set none, so _publish_ bundle re-derived the version with a "" dropbear and rejected the candidate as "does not match checkout". Download the same firecracker-inputs dropbear and export BOT_BOTTLE_FC_DROPBEAR in publish-infra (and add stage-firecracker-inputs to its needs, since it now consumes that artifact). 2. Guard stage-firecracker-inputs with the same fork-PR check as the other KVM-runner jobs, so a fork PR can't spin the privileged runner (it only copies a static binary, but keep the posture consistent; it gates the whole Firecracker chain via needs). 3. ensure_artifact_gz / _publish_bundle read version.txt before checking it exists — a bundle missing it raised a raw FileNotFoundError instead of the intended "bundle is incomplete" die. Guard the read (kept before the gz/sha completeness check so a wrong-version bundle still reports the version mismatch, not "incomplete"). 4. test_infra_artifact.setUp didn't isolate BOT_BOTTLE_INFRA_ARTIFACT_DIR, so an ambient candidate dir (the coverage job exports one) would send the registry-pull tests down the local-bundle path. Pin it off in setUp; the candidate-path cases set it explicitly. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01A9qa3xoavjQScufDfZaXKR --- .gitea/workflows/test.yml | 22 ++++++++++++++++++- .../backend/firecracker/infra_artifact.py | 7 +++++- .../backend/firecracker/publish_infra.py | 7 +++++- tests/unit/test_infra_artifact.py | 7 +++++- 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 433d880..155da5c 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -42,6 +42,15 @@ on: jobs: stage-firecracker-inputs: runs-on: [self-hosted, kvm] + # Same guard as the other KVM-runner jobs: don't spin the privileged + # runner for fork PRs (this only copies a non-secret static binary, but + # keep the posture consistent — build-infra/integration/coverage all + # depend on it, so gating here gates the whole Firecracker chain). + 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: Stage the provisioned static dropbear run: | @@ -234,7 +243,7 @@ jobs: python3 scripts/diff_coverage.py --base origin/main --min 90 publish-infra: - needs: [build-infra, unit, integration-docker, integration-firecracker, coverage] + needs: [stage-firecracker-inputs, build-infra, unit, integration-docker, integration-firecracker, coverage] runs-on: ubuntu-latest if: github.event_name == 'push' && github.ref == 'refs/heads/main' steps: @@ -247,7 +256,18 @@ jobs: 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. Stage the SAME dropbear build-infra used, or the recheck + # computes a ""-dropbear version and rejects the candidate. + - name: Download the staged dropbear (matches build-infra'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 diff --git a/bot_bottle/backend/firecracker/infra_artifact.py b/bot_bottle/backend/firecracker/infra_artifact.py index b4f5cf6..26d02da 100644 --- a/bot_bottle/backend/firecracker/infra_artifact.py +++ b/bot_bottle/backend/firecracker/infra_artifact.py @@ -169,7 +169,12 @@ def ensure_artifact_gz(version: str) -> Path: 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() + version_file = root / "version.txt" + # Guard the read so a missing version.txt is a clean error, not a raw + # FileNotFoundError. + if not version_file.is_file(): + die(f"infra candidate bundle is incomplete: {root}") + declared = version_file.read_text(encoding="utf-8").strip() if declared != version: die( f"infra candidate version mismatch: expected {version}, " diff --git a/bot_bottle/backend/firecracker/publish_infra.py b/bot_bottle/backend/firecracker/publish_infra.py index 631ec81..c4c0d1f 100644 --- a/bot_bottle/backend/firecracker/publish_infra.py +++ b/bot_bottle/backend/firecracker/publish_infra.py @@ -132,7 +132,12 @@ def build_artifact(out_dir: Path) -> tuple[str, Path, Path]: def _publish_bundle(root: Path, token: str) -> str: - version = (root / "version.txt").read_text(encoding="utf-8").strip() + version_file = root / "version.txt" + # Guard the read so a missing version.txt is a clean error, not a raw + # FileNotFoundError. + if not version_file.is_file(): + raise SystemExit(f"incomplete artifact bundle: {root}") + version = version_file.read_text(encoding="utf-8").strip() expected = infra_artifact.infra_artifact_version(infra_vm._infra_init()) if version != expected: raise SystemExit( diff --git a/tests/unit/test_infra_artifact.py b/tests/unit/test_infra_artifact.py index 51aac5f..4fccf80 100644 --- a/tests/unit/test_infra_artifact.py +++ b/tests/unit/test_infra_artifact.py @@ -50,7 +50,12 @@ class _CacheMixin(unittest.TestCase): self._env = mock.patch.dict( os.environ, {"BOT_BOTTLE_FC_CACHE": self._tmp.name, - "BOT_BOTTLE_INFRA_ARTIFACT_TOKEN": ""}, + "BOT_BOTTLE_INFRA_ARTIFACT_TOKEN": "", + # Pin the candidate-dir override off: the coverage CI job exports a + # candidate dir for the integration suite, and an ambient value + # would send these registry-pull tests down the local-bundle path. + # Cases that exercise the candidate path set it explicitly. + "BOT_BOTTLE_INFRA_ARTIFACT_DIR": ""}, clear=False, ) self._env.start()