Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 9 additions & 14 deletions .github/workflows/release-assets.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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__
Expand All @@ -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
Expand Down
35 changes: 19 additions & 16 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<version>.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-<version>.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
Expand All @@ -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 "<bridge>"`, 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-<bridge>.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:

Expand Down
118 changes: 118 additions & 0 deletions scripts/check_release_gate.py
Original file line number Diff line number Diff line change
@@ -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())
77 changes: 77 additions & 0 deletions tests/test_release_assets_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()