diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml index 05dc7d7..441cfd9 100644 --- a/.github/workflows/release-assets.yml +++ b/.github/workflows/release-assets.yml @@ -32,20 +32,6 @@ jobs: # ainative-dev-stack-2.2.1.zip beside a 2.2.2 release (AUD-201), and # every check downstream would still pass. run: python scripts/check_release_versions.py --tag "${GITHUB_REF_NAME}" - - name: V3 bridge gate (declared bridge releases only) - # The first V3 publication requires a V2-compatible bridge release that - # a V2 runtime can actually resolve (#157, PR-0B). Declared as the - # repository variable V3_BRIDGE_RELEASE; V2 releases skip it visibly, - # never silently. - env: - V3_BRIDGE_RELEASE: ${{ vars.V3_BRIDGE_RELEASE }} - run: | - set -e - if [ -z "${V3_BRIDGE_RELEASE}" ]; then - echo "SKIP: no V3_BRIDGE_RELEASE declared (not a V3 publication)" - exit 0 - fi - python scripts/check_bridge_release.py --bridge-version "${V3_BRIDGE_RELEASE}" - run: python -m pip install --disable-pip-version-check -q build - name: Build the wheel and sdist (refuses a two-version tree) # _build_backend refuses to stage when VERSION and ainative.__version__ @@ -57,6 +43,15 @@ jobs: # Filenames, wheel METADATA, sdist PKG-INFO and the protocol document # and payload VERSION inside the bundle. run: python scripts/check_release_versions.py --tag "${GITHUB_REF_NAME}" --dist dist + - name: Bridge gate (protocol-aware, #177) + # The publication protocol is derived from the built artifacts: a + # protocol 3 publication requires a bridge that a V2 runtime can + # actually resolve, and the absence of V3_BRIDGE_RELEASE is never + # treated as proof that this is a V2 release. A protocol 2 bridge + # release passes with the gate explicitly not applicable. + env: + 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 - name: Attest the build provenance of every artifact diff --git a/docs/RELEASING.md b/docs/RELEASING.md index af9ebcb..f75f858 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -27,13 +27,15 @@ The **Release assets** workflow then runs, in order: 1. the tag points at `origin/main`; 2. `check_release_versions.py --tag` (tag == VERSION == package == AGENTS.md); -3. when `V3_BRIDGE_RELEASE` is declared, the bridge gate resolves it through - the V2 selection rules and blocks a V3 publication without a verifiable - bridge; -4. wheel, sdist, lifecycle bundle build; the bundle is - `ainative-lifecycle-v2-.zip`; -5. `check_release_versions.py --dist` re-reads every artifact (filenames, wheel +3. wheel, sdist, lifecycle bundle build; the V2 bridge publishes a + `ainative-lifecycle-v2-.zip`, a V3 release publishes the external + manifest and a protocol 3 bundle (see "V2 bridge gate" below); +4. `check_release_versions.py --dist` re-reads every artifact (filenames, wheel METADATA, sdist PKG-INFO, protocol document, payload VERSION); +5. the **bridge gate** reads the built artifacts: a protocol 3 publication + requires a verified `V3_BRIDGE_RELEASE` (blocked otherwise), a protocol 2 + release reports the gate as explicitly not applicable — the absence of the + variable is never treated as proof of a V2 release; 6. `SHA256SUMS` is written; 7. **build provenance is attested** for every artifact (`actions/attest-build-provenance`): GitHub signs a statement that these @@ -49,19 +51,20 @@ release with a partial asset set. The first V3 release cannot be applied by any runtime older than V3, so every installed runtime must be able to reach the bridge release first. The bridge is -a normal release that ships the PR-0B bridge: a V2 runtime meeting a future -protocol reports `CLI_UPDATE_REQUIRED` with the upgrade path, instead of +a normal protocol 2 release that ships the PR-0B bridge: a V2 runtime meeting a +future protocol reports `CLI_UPDATE_REQUIRED` with the upgrade path, instead of `UPDATE_INTEGRITY_METADATA_MISSING`. -Before publishing a V3 release, set the repository variable -`V3_BRIDGE_RELEASE` to the bridge version. The release workflow then runs -`scripts/check_bridge_release.py --bridge-version ""`, which resolves -the bridge release through the same selection rules a user's updater uses and -blocks the publication (`BLOCK RELEASE`, exit 1) unless the release exists, is -published, and publishes `ainative-lifecycle-v2-.zip` with a digest. -When the variable is unset (V2 releases), the step reports an explicit SKIP. +The release workflow determines the protocol it is publishing **from the built +artifacts** (`scripts/check_release_gate.py`): a `dist/` holding +`ainative-release-v3.json` or an `ainative-lifecycle-v3-*.zip` is a protocol 3 +publication and requires the repository variable `V3_BRIDGE_RELEASE` to name a +bridge that a V2 runtime can actually resolve — the same resolution a user's +updater performs. A protocol 2 release passes with the gate explicitly not +applicable. A missing variable on a V3 publication, a nonexistent bridge, or a +bridge without a valid V2 lifecycle bundle all block the release. -A runtime older than the bridge sees the V3 release as +A runtime older than the bridge sees a V3 release as `UPDATE_INTEGRITY_METADATA_MISSING`; its documented escape path is the same upgrade command: diff --git a/scripts/check_release_gate.py b/scripts/check_release_gate.py new file mode 100644 index 0000000..157520b --- /dev/null +++ b/scripts/check_release_gate.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Decide whether this publication may proceed, from what was actually built. + +The V3 bridge gate used to run only when the repository variable +`V3_BRIDGE_RELEASE` was set, so a V3 publication could go out simply because +the variable was forgotten: the absence of the variable was silently treated +as proof that the publication was V2. The publication protocol is now derived +from the **built artifacts** — the absence of the variable is never evidence — +and a protocol 3 publication without a verified bridge blocks the release +(#177). + +The bridge itself is resolved through the existing V2 path +(`scripts/check_bridge_release.py` + the production `ReleaseApiProvider`): +there is no second resolver. + +Usage: + python scripts/check_release_gate.py --dist dist + V3_BRIDGE_RELEASE=2.4.4 python scripts/check_release_gate.py --dist dist --json + +Exit codes: + 0 ALLOW — the publication may proceed + 1 BLOCK — the publication must not happen + 2 usage / configuration error +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +if str(REPO) not in sys.path: + sys.path.insert(0, str(REPO)) +if str(REPO / "scripts") not in sys.path: + sys.path.insert(0, str(REPO / "scripts")) + +import check_bridge_release # noqa: E402 + +ENV_VAR = check_bridge_release.ENV_VAR +V3_MANIFEST_NAME = "ainative-release-v3.json" +V3_BUNDLE_PREFIX = "ainative-lifecycle-v3-" +V3_BUNDLE_SUFFIX = ".zip" + +ALLOW = "ALLOW" +BLOCK = "BLOCK" + + +def publication_protocol(dist: Path) -> int: + """The lifecycle protocol this publication actually carries. + + 3 when a V3 manifest or V3 lifecycle bundle was built; 2 otherwise. The + artifacts decide — never an environment variable. + """ + + if not dist.is_dir(): + return 2 + for entry in sorted(dist.iterdir()): + name = entry.name + if name == V3_MANIFEST_NAME: + return 3 + if name.startswith(V3_BUNDLE_PREFIX) and name.endswith(V3_BUNDLE_SUFFIX): + return 3 + return 2 + + +def evaluate(dist: Path, bridge_version: str, *, + verify=check_bridge_release.verify_bridge_release) -> dict: + """The gate decision, as a record. Never raises for a refusal.""" + + protocol = publication_protocol(dist) + if protocol < 3: + return {"protocol": protocol, "bridge": "not-applicable", "decision": ALLOW, + "detail": "no V3 lifecycle artifact was built; the bridge gate " + "does not apply"} + if not (bridge_version or "").strip(): + return {"protocol": protocol, "bridge": "missing", "decision": BLOCK, + "detail": f"a protocol 3 publication requires {ENV_VAR}; " + "the absence of the variable is never proof of a V2 " + "publication"} + try: + record = verify(bridge_version.strip()) + except check_bridge_release.BridgeReleaseUnverified as error: + return {"protocol": protocol, "bridge": "unverified", "decision": BLOCK, + "detail": str(error)} + return {"protocol": protocol, "bridge": "verified", "decision": ALLOW, + "detail": f"bridge release {record.get('bridge_version')} is " + "verifiable by a V2 runtime", "record": record} + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--dist", required=True, + help="the directory holding the built release artifacts") + parser.add_argument("--bridge-version", default=os.environ.get(ENV_VAR, ""), + help=f"the declared bridge release; defaults to ${ENV_VAR}") + parser.add_argument("--json", action="store_true") + args = parser.parse_args(argv) + + dist = Path(args.dist) + if not dist.is_dir(): + print(f"configuration error: no such dist directory: {dist}", file=sys.stderr) + return 2 + outcome = evaluate(dist, args.bridge_version) + if args.json: + print(json.dumps(outcome, indent=2, sort_keys=True)) + if outcome["decision"] == BLOCK: + print(f"BLOCK RELEASE: {outcome['detail']}", file=sys.stderr) + return 1 + print(f"gate: {outcome['decision']} — protocol {outcome['protocol']}; " + f"{outcome['detail']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_release_assets_gate.py b/tests/test_release_assets_gate.py index 6034d1a..2825687 100644 --- a/tests/test_release_assets_gate.py +++ b/tests/test_release_assets_gate.py @@ -32,6 +32,7 @@ import check_release_versions as gate # noqa: E402 import check_published_assets as published # noqa: E402 import check_bridge_release as bridge # noqa: E402 +import check_release_gate as release_gate # noqa: E402 from ainative.lifecycle import provider as providerlib # noqa: E402 from ainative.lifecycle.errors import LifecycleError # noqa: E402 @@ -227,5 +228,81 @@ def test_the_gate_resolves_through_the_v2_selection_path(self): self.assertEqual(record["sha256"], "b" * 64) +class BridgeGatePolicy(unittest.TestCase): + """The publication protocol comes from the artifacts, never a variable (#177). + + Non-vacuity: each of the five outcomes is pinned, and the workflow-level + simulation runs through `main()` — the same entry point the release + workflow invokes. + """ + + def dist(self, *names: str) -> Path: + directory = tempfile.TemporaryDirectory(prefix="release-gate-") + self.addCleanup(directory.cleanup) + root = Path(directory.name) + for name in names: + (root / name).write_bytes(b"x") + return root + + def unresolved(self, version: str) -> dict: + raise bridge.BridgeReleaseUnverified( + f"a V2 runtime cannot resolve the bridge release {version}: " + "UPDATE_INTEGRITY_METADATA_MISSING: no bundle") + + def resolvable(self, version: str) -> dict: + return {"bridge_version": version, "lifecycle_bundle": f"v2-{version}", + "sha256": "a" * 64, "source": "https://example.invalid"} + + def test_v2_publication_without_the_variable_is_allowed(self): + outcome = release_gate.evaluate( + self.dist("ainative-lifecycle-v2-2.4.4.zip"), "") + self.assertEqual(outcome["decision"], "ALLOW") + self.assertEqual(outcome["protocol"], 2) + self.assertEqual(outcome["bridge"], "not-applicable") + + def test_v3_publication_without_the_variable_blocks(self): + for artifacts in (("ainative-release-v3.json",), + ("ainative-lifecycle-v3-2.5.0.zip",)): + with self.subTest(artifacts=artifacts): + outcome = release_gate.evaluate(self.dist(*artifacts), "") + self.assertEqual(outcome["decision"], "BLOCK") + self.assertEqual(outcome["protocol"], 3) + self.assertIn("V3_BRIDGE_RELEASE", outcome["detail"]) + + def test_the_workflow_entry_point_blocks_a_v3_publication_without_the_variable(self): + dist = self.dist("ainative-release-v3.json", "ainative-lifecycle-v3-2.5.0.zip") + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr): + code = release_gate.main(["--dist", str(dist), "--bridge-version", ""]) + self.assertEqual(code, 1) + self.assertIn("BLOCK RELEASE", stderr.getvalue()) + + def test_v3_publication_with_a_nonexistent_bridge_blocks(self): + outcome = release_gate.evaluate(self.dist("ainative-release-v3.json"), "9.9.9", + verify=self.unresolved) + self.assertEqual(outcome["decision"], "BLOCK") + self.assertEqual(outcome["bridge"], "unverified") + self.assertIn("cannot resolve", outcome["detail"]) + + def test_v3_publication_with_a_bundle_less_bridge_blocks(self): + outcome = release_gate.evaluate(self.dist("ainative-release-v3.json"), "2.4.4", + verify=self.unresolved) + self.assertEqual(outcome["decision"], "BLOCK") + + def test_v3_publication_with_a_verifiable_bridge_passes(self): + outcome = release_gate.evaluate(self.dist("ainative-release-v3.json"), "2.4.4", + verify=self.resolvable) + self.assertEqual(outcome["decision"], "ALLOW") + self.assertEqual(outcome["bridge"], "verified") + self.assertEqual(outcome["record"]["bridge_version"], "2.4.4") + + def test_a_missing_dist_directory_is_a_configuration_error(self): + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr): + code = release_gate.main(["--dist", str(Path("nope") / "absent")]) + self.assertEqual(code, 2) + self.assertIn("configuration error", stderr.getvalue()) + + if __name__ == "__main__": unittest.main() \ No newline at end of file