From b66e8b98297d7eb918f9dd7f3cede7dfa4bf20da Mon Sep 17 00:00:00 2001 From: Rwanbt Date: Thu, 17 Sep 2026 11:58:28 +0200 Subject: [PATCH] release: 2.5.0 - Release Protocol V3 publisher (external manifest, anchored chain) (#180) --- .github/workflows/release-assets.yml | 6 +- AGENTS.md | 2 +- CHANGELOG.md | 19 ++++ README.fr.md | 4 +- README.md | 6 +- UPDATING.md | 2 +- VERSION | 2 +- ainative/__init__.py | 2 +- ainative/lifecycle/release_providers.py | 2 +- ainative/lifecycle/release_v3.py | 7 +- docs/MULTIVAULT-OPERATOR-GUIDE.md | 2 +- docs/PUBLIC-PILOT.md | 4 +- docs/RELEASING.md | 46 +++++++-- scripts/build_lifecycle_bundle.py | 111 +++++++++++++++++---- scripts/check_release_versions.py | 105 ++++++++++++++++++- scripts/lifecycle_upgrade_e2e.py | 5 +- scripts/opencode_plugin_published_e2e.py | 2 +- templates/AGENTS.md | 2 +- tests/test_lifecycle_version_invariants.py | 23 +++-- tests/test_release_assets_gate.py | 110 ++++++++++++++++++++ 20 files changed, 404 insertions(+), 58 deletions(-) diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml index 441cfd9..d588d19 100644 --- a/.github/workflows/release-assets.yml +++ b/.github/workflows/release-assets.yml @@ -38,6 +38,8 @@ jobs: # disagree; the artifact cannot exist if the invariant is broken. run: python -m build - name: Build the lifecycle bundle consumed by `ainative update` + # Protocol 3 by default since the Release V3 conversion: the bundle + # plus the external `ainative-release-v3.json` manifest that anchors it. run: python scripts/build_lifecycle_bundle.py - name: Every built artifact must carry the release version # Filenames, wheel METADATA, sdist PKG-INFO and the protocol document @@ -53,7 +55,7 @@ jobs: V3_BRIDGE_RELEASE: ${{ vars.V3_BRIDGE_RELEASE }} run: python scripts/check_release_gate.py --dist dist - name: Write SHA256SUMS - run: cd dist && sha256sum *.whl *.tar.gz *.zip > SHA256SUMS && cat SHA256SUMS + run: cd dist && sha256sum *.whl *.tar.gz *.zip *.json > SHA256SUMS && cat SHA256SUMS - name: Attest the build provenance of every artifact # Standard, verifiable, automatable: GitHub attests that these bytes # were produced by this workflow, in this repository, at this commit. @@ -78,7 +80,7 @@ jobs: --title "${GITHUB_REF_NAME}" test "$(gh release view "${GITHUB_REF_NAME}" --json isDraft --jq .isDraft)" = "true" gh release upload "${GITHUB_REF_NAME}" \ - dist/*.whl dist/*.tar.gz dist/*.zip dist/SHA256SUMS + dist/*.whl dist/*.tar.gz dist/*.zip dist/*.json dist/SHA256SUMS - name: Verify the uploaded assets, then publish the release env: GH_TOKEN: ${{ github.token }} diff --git a/AGENTS.md b/AGENTS.md index b4d8b11..b3d9062 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,5 @@ # Universal Engineering Rules - + diff --git a/CHANGELOG.md b/CHANGELOG.md index 81e8cd8..6936200 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,25 @@ project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [2.5.0] - 2026-09-17 + +Release Protocol V3: the official release publishes an external +`ainative-release-v3.json` manifest beside a protocol 3 lifecycle bundle +(`ainative-lifecycle-v3-2.5.0.zip`). The manifest's SHA-256 and size travel +as release-asset metadata (the external anchor), the runtime verifies them +before parsing anything, and the whole version chain is validated by +`scripts/check_release_versions.py --dist` before publication. The 2.4.4 +bridge remains the documented path from older runtimes. + +### Added + +- **External release manifest and protocol 3 bundles**: `scripts/build_lifecycle_bundle.py` builds protocol 3 by default and emits `ainative-release-v3.json`; the release workflow publishes both, and the protocol-aware bridge gate blocks any V3 publication without a verifiable bridge (`V3_BRIDGE_RELEASE`). +- **Protocol-aware version gate**: `check_release_versions.py` detects the lifecycle protocol from the built artifacts and validates the manifest, the exact version chain and the anchored artifact bytes (a V2/V3 mix fails). + +### Changed + +- The lifecycle bundle default is protocol 3; `--protocol 2` remains for reproducing a bridge release. + ## [2.4.4] - 2026-09-17 Multi-Forge v1: the first release carrying the generic-Git lifecycle, the diff --git a/README.fr.md b/README.fr.md index f52afed..5584e35 100644 --- a/README.fr.md +++ b/README.fr.md @@ -614,11 +614,11 @@ OpenCode et Cursor), pose `AGENTS.md` et `conventions.json`. # Relancez la ligne d'installation quand une release change le runtime du # lifecycle ; `ainative update` vous le dira (CLI_UPDATE_REQUIRED). # -# PyPI (ainative-dev-stack==2.4.4) est cable mais pas encore publie : il +# PyPI (ainative-dev-stack==2.5.0) est cable mais pas encore publie : il # manque une configuration Trusted Publisher unique sur le compte PyPI # (docs/RELEASING.md). D'ici la, la release GitHub epinglee ci-dessous est # l'installation supportee. -pip install "git+https://github.com/Rwanbt/ai-native-dev-stack.git@v2.4.4" # release epinglee (reproductible) +pip install "git+https://github.com/Rwanbt/ai-native-dev-stack.git@v2.5.0" # release epinglee (reproductible) cd /chemin/vers/votre-projet ainative setup # guide : profil, integration machine, doctor diff --git a/README.md b/README.md index dcf0af5..74cf0e5 100644 --- a/README.md +++ b/README.md @@ -607,11 +607,11 @@ manifest before parsing anything, and the support matrix is in `SUPPORT.md`. # Re-run the install line when a release changes the lifecycle runtime; # `ainative update` tells you when (CLI_UPDATE_REQUIRED). # -# PyPI (ainative-dev-stack==2.4.4) is wired but not yet published: it needs +# PyPI (ainative-dev-stack==2.5.0) is wired but not yet published: it needs # a one-time Trusted Publisher setup on the PyPI account (docs/RELEASING.md, # "PyPI"), so until then the pinned GitHub release below is the supported # install. -pip install "git+https://github.com/Rwanbt/ai-native-dev-stack.git@v2.4.4" # pinned release (reproducible) +pip install "git+https://github.com/Rwanbt/ai-native-dev-stack.git@v2.5.0" # pinned release (reproducible) cd your-project ainative setup # guided: profile, machine integration, doctor @@ -664,7 +664,7 @@ cp tools/ai_docs/config.sh.example tools/ai_docs/config.sh # Fill in Obsidian vault path, Python path, graphify binary # 4. Install the CLI; it configures the hook — no hand-edited JSON: -pip install "git+https://github.com/Rwanbt/ai-native-dev-stack.git@v2.4.4" +pip install "git+https://github.com/Rwanbt/ai-native-dev-stack.git@v2.5.0" ainative init --profile standard # merges ONE owned PostToolUse entry into .claude/settings.json # (hand-registered before the lifecycle existed? init adopts it idempotently.) diff --git a/UPDATING.md b/UPDATING.md index 50690ce..117bab1 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -49,7 +49,7 @@ update each project. `ainative update` refuses the other order with ```bash # 1. the CLI / runtime -pip install --upgrade "git+https://github.com/Rwanbt/ai-native-dev-stack.git@v2.4.4" +pip install --upgrade "git+https://github.com/Rwanbt/ai-native-dev-stack.git@v2.5.0" # 2. each project ainative update check # is there anything newer? (works with any runtime) diff --git a/VERSION b/VERSION index 79a6144..437459c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.4.4 +2.5.0 diff --git a/ainative/__init__.py b/ainative/__init__.py index 35004d8..e9f85bc 100644 --- a/ainative/__init__.py +++ b/ainative/__init__.py @@ -9,6 +9,6 @@ from __future__ import annotations -__version__ = "2.4.4" +__version__ = "2.5.0" __all__ = ["__version__"] \ No newline at end of file diff --git a/ainative/lifecycle/release_providers.py b/ainative/lifecycle/release_providers.py index 3c0e08b..b5499f4 100644 --- a/ainative/lifecycle/release_providers.py +++ b/ainative/lifecycle/release_providers.py @@ -29,7 +29,7 @@ from .errors import LifecycleError from .paths import validate_relative -MANIFEST_ASSET_NAME = "ainative-release-v3.json" +MANIFEST_ASSET_NAME = release_v3lib.MANIFEST_ASSET_NAME MAX_MANIFEST_BYTES = 1 << 20 # 1 MiB of release manifest is already absurd MAX_ARCHIVE_BYTES = 256 << 20 # 256 MiB — mirrors the V2 bound MAX_ENUMERATION_ENTRIES = 30 # one API page; beyond it, incomplete diff --git a/ainative/lifecycle/release_v3.py b/ainative/lifecycle/release_v3.py index 92d74b5..345cc14 100644 --- a/ainative/lifecycle/release_v3.py +++ b/ainative/lifecycle/release_v3.py @@ -29,6 +29,9 @@ MANIFEST_SCHEMA = "ainative.release" MANIFEST_PROTOCOL = "v3" +# The manifest is published under this exact release-asset name; the runtime +# finds it by it, and nothing else is ever treated as one. +MANIFEST_ASSET_NAME = "ainative-release-v3.json" # The `lifecycle-protocol.json` a V3 bundle carries at its root; the V2 bundle # keeps protocol 2 (provider.UPDATE_PROTOCOL_VERSION). BUNDLE_PROTOCOL_VERSION = 3 @@ -408,6 +411,6 @@ def resolve_manifest(provider: ReleaseProvider, query: ReleaseQuery "ReleaseManifest", "ReleaseProvider", "canonical_version", "select_candidate", "verify_external_anchor", "parse_manifest", "lifecycle_bundle_name", "require_exact_version_chain", "resolve_manifest", "fetch_manifest_for", - "MANIFEST_SCHEMA", "MANIFEST_PROTOCOL", "BUNDLE_PROTOCOL_VERSION", - "ARTIFACT_KIND_LIFECYCLE", + "MANIFEST_SCHEMA", "MANIFEST_PROTOCOL", "MANIFEST_ASSET_NAME", + "BUNDLE_PROTOCOL_VERSION", "ARTIFACT_KIND_LIFECYCLE", ] diff --git a/docs/MULTIVAULT-OPERATOR-GUIDE.md b/docs/MULTIVAULT-OPERATOR-GUIDE.md index 0100b7d..d1b2e88 100644 --- a/docs/MULTIVAULT-OPERATOR-GUIDE.md +++ b/docs/MULTIVAULT-OPERATOR-GUIDE.md @@ -59,7 +59,7 @@ history only. ENFORCED-DIAGNOSTIC and ENFORCED-AUTHENTICATED are not available. 1. Install and initialize the stack (pinned release; see README for the current version): ```powershell - pip install --upgrade "git+https://github.com/Rwanbt/ai-native-dev-stack.git@v2.4.4" + pip install --upgrade "git+https://github.com/Rwanbt/ai-native-dev-stack.git@v2.5.0" ainative machine init # installs the method and assets for every harness ainative machine doctor # healthy, or exit 1 naming the failing asset ``` diff --git a/docs/PUBLIC-PILOT.md b/docs/PUBLIC-PILOT.md index ebe817f..c8a4240 100644 --- a/docs/PUBLIC-PILOT.md +++ b/docs/PUBLIC-PILOT.md @@ -25,7 +25,7 @@ source pilot-venv/bin/activate # Windows (PowerShell): pilot-venv\Scripts\Activate.ps1 -pip install "git+https://github.com/Rwanbt/ai-native-dev-stack.git@v2.4.4" +pip install "git+https://github.com/Rwanbt/ai-native-dev-stack.git@v2.5.0" ainative --version ``` @@ -33,7 +33,7 @@ The last command prints the lifecycle version, the state schema and the stack version. **Pass:** it prints them without an error. Note the versions in the feedback form. -PyPI (`pip install ainative-dev-stack==2.4.4`) is not published yet — the +PyPI (`pip install ainative-dev-stack==2.5.0`) is not published yet — the one-time trusted-publisher setup on the PyPI account is still pending. The pinned release above is the supported install for this pilot. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 2f84482..e9a488e 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -19,8 +19,8 @@ reminder; the gates fail closed and none of them fixes anything silently. ```bash git checkout main && git pull --ff-only -git tag v2.4.4 -git push origin v2.4.4 +git tag v2.5.0 +git push origin v2.5.0 ``` The **Release assets** workflow then runs, in order: @@ -47,6 +47,36 @@ The **Release assets** workflow then runs, in order: A failure at any step leaves a draft release (or none) - never a visible release with a partial asset set. +### V3 releases + +Since the Release Protocol V3 conversion, the workflow builds a **protocol 3** +lifecycle bundle plus the external manifest, and the trust chain is: + +```text +provider metadata (release assets) + ↓ +external manifest anchor: SHA-256 + size (GitHub asset metadata) + ↓ +ainative-release-v3.json + ↓ +lifecycle artifact: protocol 3, exact version, SHA-256, size +``` + +`check_release_versions.py --dist` validates the whole chain — manifest +version, `compatibility.runtime_version`, artifact version, artifact filename, +the anchored bytes and the bundle's own `lifecycle-protocol.json` — before +anything is uploaded, and the protocol-aware bridge gate proves the bridge a +V2 runtime needs. The documented upgrade path for an installed project is +explicit: + +```text +old runtime → bridge release → upgrade the CLI → V3 update (`ainative update`) +``` + +A protocol 2 bridge release is still reproducible with +`python scripts/build_lifecycle_bundle.py --protocol 2`; its bundle is +`ainative-lifecycle-v2-.zip` and no manifest is generated. + ## V2 bridge gate (before the first V3 publication) The first V3 release cannot be applied by any runtime older than V3, so every @@ -100,29 +130,29 @@ workflow token, and GitHub does not let one workflow's token trigger another exists, upload an already-published release with: ```bash -gh workflow run publish-pypi.yml -f tag=v2.4.4 +gh workflow run publish-pypi.yml -f tag=v2.5.0 ``` ```bash # what a user then runs -pip install ainative-dev-stack==2.4.4 +pip install ainative-dev-stack==2.5.0 # or, isolated: -pipx install ainative-dev-stack==2.4.4 +pipx install ainative-dev-stack==2.5.0 ``` ## Verifying what was published ```bash # integrity: the bytes match the published sums -gh release download v2.4.4 --dir dist +gh release download v2.5.0 --dir dist cd dist && sha256sum -c SHA256SUMS # provenance: which workflow, repository and commit produced them -gh attestation verify dist/ainative_dev_stack-2.4.4-py3-none-any.whl -R Rwanbt/ai-native-dev-stack +gh attestation verify dist/ainative_dev_stack-2.5.0-py3-none-any.whl -R Rwanbt/ai-native-dev-stack # PyPI provenance (attestations are published with the files) python -m pip install --upgrade pypi-attestations -python -m pypi_attestations verify dist/ainative_dev_stack-2.4.4-py3-none-any.whl +python -m pypi_attestations verify dist/ainative_dev_stack-2.5.0-py3-none-any.whl ``` `SHA256SUMS` alone proves integrity against the source that published it; it diff --git a/scripts/build_lifecycle_bundle.py b/scripts/build_lifecycle_bundle.py index 68750c3..80fe589 100644 --- a/scripts/build_lifecycle_bundle.py +++ b/scripts/build_lifecycle_bundle.py @@ -1,19 +1,30 @@ #!/usr/bin/env python3 """Build the lifecycle bundle published with every release. -The official update path consumes -`ainative-lifecycle-v2-.zip`: a protocol document at the archive root, -the installer payload under `stack/`, and a `protocol/` directory that describes -the format. The wrapping is deliberate. Runtimes up to v2.2.2 selected any -`ainative-dev-stack-*.zip` and found the payload by looking for a single -top-level directory with a VERSION file; this layout names another file and -ships two top-level directories, so a legacy runtime refuses it even when a -mirror hands it the file directly (AUD-205). +Two generations share this builder, and the caller says which one is being +published: + +* **protocol 2** (the historical shape, kept for reproducing a bridge) — + `ainative-lifecycle-v2-.zip`: a protocol document at the archive + root, the installer payload under `stack/`, and a `protocol/` directory. + Runtimes up to v2.2.2 selected any `ainative-dev-stack-*.zip` and found the + payload by looking for a single top-level directory with a VERSION file; + this layout names another file and ships two top-level directories, so a + legacy runtime refuses it even when a mirror hands it the file directly + (AUD-205). +* **protocol 3** (the default since the Release V3 conversion) — + `ainative-lifecycle-v3-.zip` plus the external + `ainative-release-v3.json` manifest that anchors it: schema, protocol, + version, channel, `compatibility.runtime_version`, the artifact's name, + version, SHA-256 and byte size, and its provenance. The runtime verifies the + manifest against provider metadata (its SHA-256 and size, obtained from the + release assets) *before* it parses anything, and the exact version chain must + hold end to end (ADR-0019). Refuses to build when VERSION and ainative.__version__ disagree (#125). Usage: - python scripts/build_lifecycle_bundle.py [--outdir dist] + python scripts/build_lifecycle_bundle.py [--outdir dist] [--protocol {2,3}] """ from __future__ import annotations @@ -31,6 +42,7 @@ sys.path.insert(0, str(REPO)) from _payload_staging import assert_version_consistency, stage_payload # noqa: E402 +from ainative.lifecycle import release_v3 as release_v3lib # noqa: E402 from ainative.lifecycle.provider import (PROTOCOL_MANIFEST, # noqa: E402 lifecycle_bundle_name, protocol_document) @@ -38,7 +50,12 @@ # archive only because of file mtimes. FIXED_TIMESTAMP = (2026, 1, 1, 0, 0, 0) -PROTOCOL_README = """# Lifecycle bundle protocol v2 +PROTOCOL_V2 = 2 +PROTOCOL_V3 = 3 +V3_MANIFEST_NAME = release_v3lib.MANIFEST_ASSET_NAME + +PROTOCOL_README = { + PROTOCOL_V2: """# Lifecycle bundle protocol v2 The payload this archive carries is installed by a lifecycle runtime that speaks protocol v2. The layout is: @@ -50,7 +67,22 @@ A runtime older than v2.2.2 selects bundles by the `ainative-dev-stack-*` name and looks for the payload in a single top-level directory. It therefore cannot consume this archive - by design. Upgrade the CLI first. -""" +""", + PROTOCOL_V3: """# Lifecycle bundle protocol v3 + +The payload this archive carries is installed by a lifecycle runtime that +speaks protocol v3. The layout is: + + lifecycle-protocol.json protocol version 3, release version, payload root + stack/ the distribution payload (VERSION at its root) + protocol/ this document + +The release also publishes `ainative-release-v3.json`, whose SHA-256 and size +are obtained from the release assets (the external anchor) and verified before +the manifest is parsed. A protocol 2 runtime reports `CLI_UPDATE_REQUIRED` for +this release: upgrade the CLI through the bridge release first. +""", +} def _write(archive: zipfile.ZipFile, name: str, payload: bytes) -> None: @@ -60,37 +92,76 @@ def _write(archive: zipfile.ZipFile, name: str, payload: bytes) -> None: archive.writestr(info, payload) -def build(outdir: Path) -> Path: - """Pack the staged payload into the v2 lifecycle bundle for this tree.""" +def _bundle_name(version: str, protocol: int) -> str: + if protocol == PROTOCOL_V3: + return release_v3lib.lifecycle_bundle_name(version) + return lifecycle_bundle_name(version) + + +def _protocol_payload(version: str, protocol: int) -> dict: + if protocol == PROTOCOL_V3: + return {"schema_name": "lifecycle_protocol", "protocol_version": PROTOCOL_V3, + "release_version": version, "payload_root": "stack"} + return protocol_document(version) + + +def build(outdir: Path, protocol: int = PROTOCOL_V3) -> tuple[Path, Path | None]: + """Pack the staged payload and return (bundle, manifest-or-None).""" + if protocol not in (PROTOCOL_V2, PROTOCOL_V3): + raise ValueError(f"unknown lifecycle protocol {protocol!r}") version = assert_version_consistency(REPO) outdir.mkdir(parents=True, exist_ok=True) - bundle = outdir / lifecycle_bundle_name(version) + bundle = outdir / _bundle_name(version, protocol) with tempfile.TemporaryDirectory(prefix="ainative-bundle-") as staging: payload = stage_payload(REPO, Path(staging) / "payload") entries = sorted(path for path in payload.rglob("*") if path.is_file()) with zipfile.ZipFile(bundle, "w", zipfile.ZIP_DEFLATED) as archive: _write(archive, PROTOCOL_MANIFEST, - (json.dumps(protocol_document(version), indent=2, sort_keys=True) - + "\n").encode("utf-8")) - _write(archive, "protocol/README.md", PROTOCOL_README.encode("utf-8")) + (json.dumps(_protocol_payload(version, protocol), indent=2, + sort_keys=True) + "\n").encode("utf-8")) + _write(archive, "protocol/README.md", + PROTOCOL_README[protocol].encode("utf-8")) for path in entries: _write(archive, f"stack/{path.relative_to(payload).as_posix()}", path.read_bytes()) - return bundle + if protocol == PROTOCOL_V2: + return bundle, None + data = bundle.read_bytes() + manifest = { + "schema": release_v3lib.MANIFEST_SCHEMA, + "protocol": release_v3lib.MANIFEST_PROTOCOL, + "version": version, + "channel": "stable", + "compatibility": {"runtime_version": version}, + "artifacts": [{"name": bundle.name, "kind": release_v3lib.ARTIFACT_KIND_LIFECYCLE, + "version": version, + "sha256": hashlib.sha256(data).hexdigest(), + "size": len(data)}], + "provenance": {"source": "release pipeline", + "repository": "Rwanbt/ai-native-dev-stack"}, + } + manifest_path = outdir / V3_MANIFEST_NAME + manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + return bundle, manifest_path def main() -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--outdir", default="dist", help="where to write the bundle") + parser.add_argument("--protocol", type=int, choices=(PROTOCOL_V2, PROTOCOL_V3), + default=PROTOCOL_V3, + help="lifecycle protocol to build (default: 3)") args = parser.parse_args() - bundle = build(Path(args.outdir)) + bundle, manifest = build(Path(args.outdir), args.protocol) digest = hashlib.sha256(bundle.read_bytes()).hexdigest() print(f"bundle: {bundle}") print(f"sha256: {digest}") + if manifest is not None: + print(f"manifest: {manifest}") return 0 if __name__ == "__main__": - raise SystemExit(main()) \ No newline at end of file + raise SystemExit(main()) diff --git a/scripts/check_release_versions.py b/scripts/check_release_versions.py index 40eec63..b3da619 100644 --- a/scripts/check_release_versions.py +++ b/scripts/check_release_versions.py @@ -30,6 +30,7 @@ from __future__ import annotations import argparse +import hashlib import json import re import sys @@ -42,6 +43,8 @@ sys.path.insert(0, str(REPO)) from _payload_staging import assert_version_consistency # noqa: E402 +from ainative.lifecycle import release_v3 as release_v3lib # noqa: E402 +from ainative.lifecycle.errors import LifecycleError # noqa: E402 from ainative.lifecycle.provider import (PROTOCOL_MANIFEST, # noqa: E402 UPDATE_PROTOCOL_VERSION, lifecycle_bundle_name) @@ -125,6 +128,86 @@ def _check_bundle(version: str, dist: Path, checked: set[str]) -> None: checked.add(bundle_name) +def detect_protocol(dist: Path) -> int | None: + """The lifecycle protocol this dist carries, from the artifacts themselves.""" + + names = {item.name for item in dist.iterdir() if item.is_file()} + has_v3 = release_v3lib.MANIFEST_ASSET_NAME in names or any( + name.startswith("ainative-lifecycle-v3-") and name.endswith(".zip") + for name in names) + has_v2 = any(name.startswith("ainative-lifecycle-v2-") and name.endswith(".zip") + for name in names) + if has_v3 and has_v2: + raise ReleaseVersionMismatch( + "the dist mixes a V2 lifecycle bundle with V3 artifacts; a release " + "is exactly one protocol") + if has_v3: + return release_v3lib.BUNDLE_PROTOCOL_VERSION + if has_v2: + return UPDATE_PROTOCOL_VERSION + return None + + +def _check_v3(version: str, dist: Path, checked: set[str]) -> None: + """The V3 chain: manifest, exact version links, anchored artifact bytes.""" + + manifest_path = dist / release_v3lib.MANIFEST_ASSET_NAME + if not manifest_path.is_file(): + raise ReleaseVersionMismatch( + f"the V3 manifest {release_v3lib.MANIFEST_ASSET_NAME!r} is missing " + f"from {dist}") + try: + manifest = release_v3lib.parse_manifest(manifest_path.read_bytes()) + artifact = manifest.lifecycle_artifact() + candidate = release_v3lib.ReleaseCandidate( + version=manifest.version, identity=manifest.version) + release_v3lib.require_exact_version_chain(candidate, manifest, artifact) + except LifecycleError as refusal: + raise ReleaseVersionMismatch( + f"the V3 manifest is invalid: {refusal.code}: {refusal.message}") from refusal + if manifest.version != version: + raise ReleaseVersionMismatch( + f"the V3 manifest declares version {manifest.version!r} but the " + f"release is {version!r}") + if manifest.runtime_version != version: + raise ReleaseVersionMismatch( + f"the V3 manifest requires runtime {manifest.runtime_version!r} but " + f"the release is {version!r}") + bundle = dist / artifact.name + if not bundle.is_file(): + raise ReleaseVersionMismatch( + f"the lifecycle artifact {artifact.name!r} is missing from {dist}") + payload = bundle.read_bytes() + actual_digest = hashlib.sha256(payload).hexdigest() + if len(payload) != artifact.size or actual_digest != artifact.sha256: + raise ReleaseVersionMismatch( + f"the lifecycle artifact {artifact.name!r} does not match the " + f"manifest (size {len(payload)} vs {artifact.size}, sha256 " + f"{actual_digest} vs {artifact.sha256})") + with zipfile.ZipFile(bundle) as archive: + names = archive.namelist() + if PROTOCOL_MANIFEST not in names: + raise ReleaseVersionMismatch( + f"lifecycle bundle {artifact.name!r} carries no {PROTOCOL_MANIFEST}") + document = json.loads(archive.read(PROTOCOL_MANIFEST).decode("utf-8")) + if document.get("protocol_version") != release_v3lib.BUNDLE_PROTOCOL_VERSION: + raise ReleaseVersionMismatch( + f"lifecycle bundle {artifact.name!r} declares protocol " + f"{document.get('protocol_version')!r}, expected " + f"{release_v3lib.BUNDLE_PROTOCOL_VERSION}") + if document.get("release_version") != version: + raise ReleaseVersionMismatch( + f"lifecycle bundle {artifact.name!r} declares release_version " + f"{document.get('release_version')!r}") + payload_root = str(document.get("payload_root", "")) + payload_version = archive.read(f"{payload_root}/VERSION").decode("utf-8").strip() + if payload_version != version: + raise ReleaseVersionMismatch( + f"lifecycle bundle {artifact.name!r} contains VERSION {payload_version!r}") + checked.add(manifest_path.name) + checked.add(artifact.name) + + def _check_wheel(version: str, dist: Path, checked: set[str]) -> None: wheels = sorted(dist.glob(f"ainative_dev_stack-{version}-*.whl")) if not wheels: @@ -164,9 +247,13 @@ def _check_sdist(version: str, dist: Path, checked: set[str]) -> None: checked.add(sdist.name) -def check_dist(version: str, dist: Path, *, require_bundle: bool = True) -> list[str]: +def check_dist(version: str, dist: Path, *, require_bundle: bool = True, + protocol: int | None = None) -> list[str]: """Each built artifact must be named for, and declare, `version`. + The lifecycle protocol is detected from the artifacts themselves (a V3 + manifest or V3 bundle means protocol 3; a V2 bundle means protocol 2; both + together are refused), unless `protocol` states the expectation. `require_bundle=False` is the PyPI path: PyPI ships the wheel and the sdist, not the lifecycle bundle, which GitHub Releases carries. """ @@ -175,7 +262,16 @@ def check_dist(version: str, dist: Path, *, require_bundle: bool = True) -> list raise ReleaseVersionMismatch(f"no dist directory at {dist}") checked: set[str] = set() if require_bundle: - _check_bundle(version, dist, checked) + actual = detect_protocol(dist) + if protocol is not None and actual is not None and actual != protocol: + raise ReleaseVersionMismatch( + f"the dist carries lifecycle protocol {actual} but protocol " + f"{protocol} was expected") + expected = protocol if protocol is not None else actual + if expected == release_v3lib.BUNDLE_PROTOCOL_VERSION: + _check_v3(version, dist, checked) + else: + _check_bundle(version, dist, checked) _check_wheel(version, dist, checked) _check_sdist(version, dist, checked) stale = _stale_artifacts(dist, checked, version) @@ -193,6 +289,8 @@ def main() -> int: help="the release tag being published, e.g. v2.2.2") parser.add_argument("--dist", type=Path, default=None, help="also verify the built artifacts in this directory") + parser.add_argument("--protocol", type=int, choices=(2, 3), default=None, + help="the lifecycle protocol to expect; default: from the dist") parser.add_argument("--without-bundle", action="store_true", help="check only the wheel and the sdist (the PyPI surface)") args = parser.parse_args() @@ -200,7 +298,8 @@ def main() -> int: try: version = check_labels(args.root, args.tag) artifacts = (check_dist(version, args.dist, - require_bundle=not args.without_bundle) + require_bundle=not args.without_bundle, + protocol=args.protocol) if args.dist else []) except ReleaseVersionMismatch as refusal: print(f"RELEASE_VERSION_MISMATCH: {refusal}", file=sys.stderr) diff --git a/scripts/lifecycle_upgrade_e2e.py b/scripts/lifecycle_upgrade_e2e.py index fb0893b..bfffcd5 100644 --- a/scripts/lifecycle_upgrade_e2e.py +++ b/scripts/lifecycle_upgrade_e2e.py @@ -146,8 +146,11 @@ def build_wheel(tree: Path, output: Path) -> Path: def build_bundle(tree: Path, output: Path) -> Path: + # This probe exercises the V2 transport and transaction path on purpose: + # the builder defaults to protocol 3 since the Release V3 conversion, so + # the probe asks for the historical shape explicitly. run([sys.executable, str(tree / "scripts" / "build_lifecycle_bundle.py"), - "--outdir", output]) + "--outdir", output, "--protocol", "2"]) bundles = sorted(output.glob("ainative-lifecycle-v2-*.zip")) if len(bundles) != 1: raise Failure(f"expected one bundle from {tree}, found {bundles}") diff --git a/scripts/opencode_plugin_published_e2e.py b/scripts/opencode_plugin_published_e2e.py index e6c277a..e0510c5 100644 --- a/scripts/opencode_plugin_published_e2e.py +++ b/scripts/opencode_plugin_published_e2e.py @@ -17,7 +17,7 @@ Usage: python scripts/opencode_plugin_published_e2e.py - python scripts/opencode_plugin_published_e2e.py --wheel dist/ainative_dev_stack-2.4.4-py3-none-any.whl + python scripts/opencode_plugin_published_e2e.py --wheel dist/ainative_dev_stack-2.5.0-py3-none-any.whl python scripts/opencode_plugin_published_e2e.py --keep """ diff --git a/templates/AGENTS.md b/templates/AGENTS.md index d8ae0e8..7381526 100644 --- a/templates/AGENTS.md +++ b/templates/AGENTS.md @@ -1,5 +1,5 @@ # Universal Engineering Rules - + diff --git a/tests/test_lifecycle_version_invariants.py b/tests/test_lifecycle_version_invariants.py index f1b0913..4e5666b 100644 --- a/tests/test_lifecycle_version_invariants.py +++ b/tests/test_lifecycle_version_invariants.py @@ -27,6 +27,7 @@ from _payload_staging import (VersionMismatch, assert_version_consistency, # noqa: E402 package_version, read_version, stage_payload) from ainative.lifecycle import source as sourcelib # noqa: E402 +from ainative.lifecycle.digest import digest_bytes # noqa: E402 from tests.lifecycle_support import write_text # noqa: E402 @@ -63,21 +64,29 @@ def test_the_lifecycle_bundle_carries_the_package_version(self): scripts = REPO / "scripts" if str(scripts) not in sys.path: sys.path.insert(0, str(scripts)) - from build_lifecycle_bundle import build + from build_lifecycle_bundle import PROTOCOL_V3, build + version = ainative.__version__ with tempfile.TemporaryDirectory(prefix="ainative-bundle-") as staging: - bundle = build(Path(staging) / "dist") - self.assertEqual(bundle.name, - f"ainative-lifecycle-v2-{ainative.__version__}.zip") + bundle, manifest_path = build(Path(staging) / "dist") + # Protocol 3 is the default since the Release V3 conversion. + self.assertEqual(bundle.name, f"ainative-lifecycle-v3-{version}.zip") with zipfile.ZipFile(bundle) as archive: document = json.loads(archive.read( "lifecycle-protocol.json").decode("utf-8")) - self.assertEqual(document["protocol_version"], 2) - self.assertEqual(document["release_version"], ainative.__version__) + self.assertEqual(document["protocol_version"], PROTOCOL_V3) + self.assertEqual(document["release_version"], version) payload_root = document["payload_root"] self.assertEqual( archive.read(f"{payload_root}/VERSION").decode("utf-8").strip(), - ainative.__version__) + version) + manifest = json.loads(Path(manifest_path).read_text(encoding="utf-8")) + self.assertEqual(manifest["version"], version) + self.assertEqual(manifest["compatibility"]["runtime_version"], version) + artifact = manifest["artifacts"][0] + self.assertEqual(artifact["name"], bundle.name) + self.assertEqual(artifact["sha256"], digest_bytes(bundle.read_bytes())) + self.assertEqual(artifact["size"], bundle.stat().st_size) def test_two_disagreeing_labels_refuse_the_build(self): with tempfile.TemporaryDirectory(prefix="ainative-mismatch-") as staging: diff --git a/tests/test_release_assets_gate.py b/tests/test_release_assets_gate.py index 2825687..b1ac26b 100644 --- a/tests/test_release_assets_gate.py +++ b/tests/test_release_assets_gate.py @@ -304,5 +304,115 @@ def test_a_missing_dist_directory_is_a_configuration_error(self): self.assertIn("configuration error", stderr.getvalue()) +def make_v3_bundle(dist: Path, version: str, *, protocol: int = 3, + payload_version: str | None = None) -> Path: + path = dist / f"ainative-lifecycle-v3-{version}.zip" + document = {"schema_name": "lifecycle_protocol", "protocol_version": protocol, + "release_version": version, "payload_root": "stack"} + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("lifecycle-protocol.json", json.dumps(document)) + archive.writestr("stack/VERSION", f"{payload_version or version}\n") + return path + + +def make_v3_manifest(dist: Path, version: str, *, manifest_version: str | None = None, + runtime: str | None = None, artifact_name: str | None = None, + artifact_version: str | None = None, artifact_sha: str | None = None, + artifact_size: int | None = None) -> Path: + import hashlib + + bundle = dist / f"ainative-lifecycle-v3-{version}.zip" + payload = bundle.read_bytes() + document = { + "schema": "ainative.release", "protocol": "v3", + "version": manifest_version or version, "channel": "stable", + "compatibility": {"runtime_version": runtime or version}, + "artifacts": [{ + "name": artifact_name or f"ainative-lifecycle-v3-{version}.zip", + "kind": "lifecycle", "version": artifact_version or version, + "sha256": artifact_sha or hashlib.sha256(payload).hexdigest(), + "size": artifact_size if artifact_size is not None else len(payload)}], + "provenance": {"source": "test"}, + } + path = dist / "ainative-release-v3.json" + path.write_text(json.dumps(document), encoding="utf-8") + return path + + +class V3DistChain(unittest.TestCase): + """A V3 dist is validated through the manifest, the anchor and the chain.""" + + VERSION = "2.5.0" + + def dist(self) -> Path: + directory = tempfile.TemporaryDirectory(prefix="v3-dist-") + self.addCleanup(directory.cleanup) + root = Path(directory.name) + make_wheel(root, self.VERSION) + make_sdist(root, self.VERSION) + make_v3_bundle(root, self.VERSION) + make_v3_manifest(root, self.VERSION) + return root + + def test_a_v3_dist_passes(self): + checked = gate.check_dist(self.VERSION, self.dist()) + self.assertIn(f"ainative-lifecycle-v3-{self.VERSION}.zip", checked) + self.assertIn("ainative-release-v3.json", checked) + + def test_the_wrong_protocol_inside_the_bundle_is_refused(self): + root = self.dist() + (root / f"ainative-lifecycle-v3-{self.VERSION}.zip").unlink() + make_v3_bundle(root, self.VERSION, protocol=2) + make_v3_manifest(root, self.VERSION) + with self.assertRaises(gate.ReleaseVersionMismatch) as raised: + gate.check_dist(self.VERSION, root) + self.assertIn("declares protocol 2", str(raised.exception)) + + def test_a_manifest_naming_another_version_is_refused(self): + root = self.dist() + make_v3_manifest(root, self.VERSION, manifest_version="2.5.1") + with self.assertRaises(gate.ReleaseVersionMismatch): + gate.check_dist(self.VERSION, root) + + def test_a_runtime_mismatch_is_refused(self): + root = self.dist() + make_v3_manifest(root, self.VERSION, runtime="2.4.9") + with self.assertRaises(gate.ReleaseVersionMismatch): + gate.check_dist(self.VERSION, root) + + def test_a_tampered_bundle_is_refused_by_the_anchor(self): + root = self.dist() + make_v3_manifest(root, self.VERSION, artifact_sha="0" * 64) + with self.assertRaises(gate.ReleaseVersionMismatch) as raised: + gate.check_dist(self.VERSION, root) + self.assertIn("does not match the manifest", str(raised.exception)) + + def test_a_wrong_artifact_filename_is_refused(self): + root = self.dist() + make_v3_manifest(root, self.VERSION, artifact_name="other.zip") + with self.assertRaises(gate.ReleaseVersionMismatch): + gate.check_dist(self.VERSION, root) + + def test_the_bundle_internal_version_must_agree(self): + root = self.dist() + (root / f"ainative-lifecycle-v3-{self.VERSION}.zip").unlink() + make_v3_bundle(root, self.VERSION, payload_version="2.4.9") + make_v3_manifest(root, self.VERSION) + with self.assertRaises(gate.ReleaseVersionMismatch): + gate.check_dist(self.VERSION, root) + + def test_a_mixed_v2_v3_dist_is_refused(self): + root = self.dist() + (root / f"ainative-lifecycle-v2-{self.VERSION}.zip").write_bytes(b"x") + with self.assertRaises(gate.ReleaseVersionMismatch) as raised: + gate.check_dist(self.VERSION, root) + self.assertIn("mixes", str(raised.exception)) + + def test_forcing_protocol_2_on_a_v3_dist_is_refused(self): + with self.assertRaises(gate.ReleaseVersionMismatch) as raised: + gate.check_dist(self.VERSION, self.dist(), protocol=2) + self.assertIn("protocol 3", str(raised.exception)) + + if __name__ == "__main__": unittest.main() \ No newline at end of file