From 4544899c79cbc13667a5a8f3eb5a064f8d56a7e6 Mon Sep 17 00:00:00 2001 From: codex Date: Mon, 27 Jul 2026 17:18:36 +0000 Subject: [PATCH] docs(prd): finalize complete release flow --- .gitea/workflows/release.yml | 2 +- bot_bottle/release_qualification.py | 26 +++++++++++++--- docs/ci.md | 17 +++++++++++ docs/prds/0083-packaged-infra-artifacts.md | 20 +++++++------ tests/unit/test_release_qualification.py | 35 ++++++++++++++++++++-- 5 files changed, 84 insertions(+), 16 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 47c937aa..98d74645 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -71,7 +71,7 @@ jobs: BOT_BOTTLE_REF: ${{ needs.validate.outputs.sha }} run: | sh install.sh - bot-bottle --help + "$HOME/.local/bin/bot-bottle" --help - name: Publish release qualification and advance channel env: BOT_BOTTLE_RELEASE_TOKEN: ${{ secrets.BOT_BOTTLE_RELEASE_TOKEN }} diff --git a/bot_bottle/release_qualification.py b/bot_bottle/release_qualification.py index 53e6a748..cf5ad18a 100644 --- a/bot_bottle/release_qualification.py +++ b/bot_bottle/release_qualification.py @@ -8,6 +8,7 @@ import urllib.error import urllib.request from typing import Any +from .release_bundle import parse_bundle_index from .release_manifest import ReleaseManifestError from .release_publish import ( HTTP_TIMEOUT_SECONDS, @@ -37,11 +38,20 @@ def release_version(tag: str) -> tuple[int, int, int, int]: """Return a monotonically comparable version tuple.""" stable = _STABLE_TAG_RE.fullmatch(tag) if stable: - return *(int(value) for value in stable.groups()), 1 << 30 + return ( + int(stable.group(1)), + int(stable.group(2)), + int(stable.group(3)), + 1 << 30, + ) staging = _STAGING_TAG_RE.fullmatch(tag) if staging: - return *(int(value) for value in staging.groups()[:3]), int( - staging.group(4)) + return ( + int(staging.group(1)), + int(staging.group(2)), + int(staging.group(3)), + int(staging.group(4)), + ) raise ReleaseManifestError( "release tag must be vX.Y.Z or vX.Y.Z-rc.N") @@ -138,9 +148,17 @@ def publish_qualification( channel_url = pointer_url( "bot-bottle-channels", channel, "channel.json") - if remote_bytes(str(pointer["bundle_index_url"])) is None: + bundle = remote_bytes(str(pointer["bundle_index_url"])) + if bundle is None: raise ReleaseManifestError( f"commit bundle does not exist for {pointer['source_commit']}") + try: + bundle_data = parse_bundle_index(json.loads(bundle)) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + raise ReleaseManifestError("commit bundle index is malformed") from exc + if bundle_data["source_commit"] != pointer["source_commit"]: + raise ReleaseManifestError( + "commit bundle index does not match the qualified source commit") existing_release = remote_bytes(release_url) if existing_release is not None and existing_release != wanted: raise ReleaseManifestError( diff --git a/docs/ci.md b/docs/ci.md index 17c019f4..62c25ee7 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -45,6 +45,23 @@ report; macOS reports advisory coverage in its own job. The macOS infra container is a singleton, so its job uses a concurrency group and always tears the service down. +## Artifact publication and promotion + +[`publish-artifacts.yml`](../.gitea/workflows/publish-artifacts.yml) is the +manual deployment-test boundary. Dispatch it with any branch, tag, or commit; +it resolves that input once, publishes the orchestrator, gateway, Claude, +Codex, Pi, and Firecracker artifacts, then publishes only the wheel verified +against those identities. Install the reported commit with +`BOT_BOTTLE_REF= sh install.sh`; the installer warns that snapshots may +not have completed release qualification. + +[`release.yml`](../.gitea/workflows/release.yml) handles promotion tags. +`vX.Y.Z-rc.N` must be reachable from `staging`; `vX.Y.Z` must be reachable +from `production`. It runs the complete pre-release workflow, ensures that the +same commit bundle is published, verifies a clean install, creates the +qualified release pointer, and advances the matching channel. Promotion +reuses immutable bundle bytes and never rebuilds them. + ## Scheduled canary [`.gitea/workflows/canaries.yml`](../.gitea/workflows/canaries.yml) runs weekly diff --git a/docs/prds/0083-packaged-infra-artifacts.md b/docs/prds/0083-packaged-infra-artifacts.md index df8d113d..582a276f 100644 --- a/docs/prds/0083-packaged-infra-artifacts.md +++ b/docs/prds/0083-packaged-infra-artifacts.md @@ -1,6 +1,6 @@ # PRD 0083: Packaged infrastructure artifacts -- **Status:** Draft +- **Status:** Active - **Author:** Codex - **Created:** 2026-07-27 - **Issue:** #536 @@ -167,7 +167,8 @@ immutable external bundle index under that SHA. The index contains: - Claude, Codex, and Pi agent OCI digest references; - Firecracker package versions and compressed-file SHA-256 values; - producing workflow/run identity and publication timestamp; and -- qualification records, if any, naming the test workflow and release tag. +- producing workflow provenance. Qualification is recorded separately in an + immutable tag pointer so the commit bundle itself never changes. The embedded wheel manifest contains the runtime subset of the same identities. Publication fails if the embedded manifest and external index differ. @@ -234,7 +235,7 @@ The release workflow is triggered by an eligible staging or production tag: then if absent; otherwise verify every existing identity and checksum. 4. Install using the published installer and bundle index on clean supported hosts, and run the post-install smoke test against the acquired artifacts. -5. Attach a qualification record to the bundle, create the Gitea release, and +5. Publish an immutable qualified tag pointer, create the Gitea release, and advance the matching channel pointer. No release artifact or channel update occurs before qualification succeeds. @@ -256,9 +257,9 @@ and publish them first, then install the reported SHA. An exact-commit install prints a prominent warning that the snapshot may not have passed release qualification, even if the same SHA is later associated -with a tag. A tag or channel install suppresses that warning only when the -external index contains a successful qualification record for that tag and -the tag selects the same bundle SHA. +with a tag. A tag or channel install suppresses that warning only when its +validated pointer is marked qualified and selects the same immutable bundle +SHA. The installer downloads the wheel named by the external index, verifies its SHA-256 before invoking pipx or pip, and reports the selected channel/tag, @@ -361,9 +362,10 @@ Firecracker, and Apple Container. Create and protect `staging` and `production`, then promote one commit from `main` through both branches. Publish its commit bundle, qualify an RC tag, -perform clean installs through the staging channel, promote the same SHA, -qualify a stable tag, and only then make the production channel the installer's -default. The stable promotion must demonstrate that every production artifact +perform clean installs through the staging channel, promote the same SHA, and +qualify a stable tag. Until that first stable qualification exists, the +installer's production default fails closed because there is no production +pointer. The stable promotion must demonstrate that every production artifact identity matches the staging-qualified bundle. Existing source checkouts use explicit local mode. Existing mutable local diff --git a/tests/unit/test_release_qualification.py b/tests/unit/test_release_qualification.py index c719a7d8..898cbc99 100644 --- a/tests/unit/test_release_qualification.py +++ b/tests/unit/test_release_qualification.py @@ -16,6 +16,37 @@ from bot_bottle.release_qualification import ( COMMIT = "a" * 40 +def bundle() -> bytes: + return json.dumps({ + "schema": 1, + "source_commit": COMMIT, + "wheel": { + "filename": "bot_bottle.whl", + "url": ( + "https://gitea.dideric.is/api/packages/didericis/generic/" + f"bot-bottle-builds/{COMMIT}/bot_bottle.whl" + ), + "sha256": "b" * 64, + }, + "oci": { + role: f"registry.example/{role}@sha256:{'c' * 64}" + for role in ( + "orchestrator", + "gateway", + "agent_claude", + "agent_codex", + "agent_pi", + ) + }, + "firecracker": { + role: {"version": "v1", "sha256": "d" * 64} + for role in ("orchestrator", "gateway") + }, + "provenance": {"workflow_run": "run", "published_at": "now"}, + "qualifications": [], + }).encode() + + class ReleaseQualificationTest(unittest.TestCase): def test_tag_selects_channel(self) -> None: self.assertEqual(release_channel("v1.2.3"), "production") @@ -38,7 +69,7 @@ class ReleaseQualificationTest(unittest.TestCase): def test_publishes_bundle_release_and_channel( self, remote: mock.Mock, upload: mock.Mock, ) -> None: - remote.side_effect = [b"bundle", None, None] + remote.side_effect = [bundle(), None, None] pointer = build_release_pointer( tag="v1.2.3", source_commit=COMMIT, @@ -59,7 +90,7 @@ class ReleaseQualificationTest(unittest.TestCase): workflow_run="old-run", qualified_at="old-time", ) - remote.side_effect = [b"bundle", None, json.dumps(old).encode()] + remote.side_effect = [bundle(), None, json.dumps(old).encode()] pointer = build_release_pointer( tag="v1.2.3", source_commit=COMMIT,