diff --git a/.github/workflows/cut-ironclaw-release.yml b/.github/workflows/cut-ironclaw-release.yml new file mode 100644 index 0000000000..1386dfb997 --- /dev/null +++ b/.github/workflows/cut-ironclaw-release.yml @@ -0,0 +1,62 @@ +name: Cut Ironclaw Release + +on: + workflow_dispatch: + inputs: + version: + description: "Cargo version to release (for example, 1.1.0-rc.1)" + required: true + type: string + commit_sha: + description: "Full commit SHA approved for this immutable release" + required: true + type: string + +permissions: + contents: read + +concurrency: + group: cut-ironclaw-${{ inputs.version }} + cancel-in-progress: false + +jobs: + tag: + name: Tag approved commit + if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + runs-on: ubuntu-24.04 + environment: ironclaw-release + steps: + - name: Checkout release tooling + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + with: + ref: ${{ github.sha }} + path: release-tools + persist-credentials: false + - name: Checkout approved commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + with: + ref: ${{ inputs.commit_sha }} + path: candidate + persist-credentials: false + - name: Install Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v6 + with: + python-version: "3.12" + - name: Generate GitHub token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + id: generate-token + with: + app-id: ${{ secrets.GH_RELEASES_MANAGER_APP_ID }} + private-key: ${{ secrets.GH_RELEASES_MANAGER_APP_PRIVATE_KEY }} + - name: Create immutable cargo-dist tag + env: + COMMIT_SHA: ${{ inputs.commit_sha }} + GH_TOKEN: ${{ steps.generate-token.outputs.token }} + RELEASE_VERSION: ${{ inputs.version }} + REPOSITORY: ${{ github.repository }} + run: | + python3 release-tools/scripts/ci/cut_ironclaw_release.py \ + --version "$RELEASE_VERSION" \ + --commit-sha "$COMMIT_SHA" \ + --candidate-root candidate \ + --repository "$REPOSITORY" diff --git a/docs/internal/weekly-release-strategy.md b/docs/internal/weekly-release-strategy.md index e3117881a1..25ec97d996 100644 --- a/docs/internal/weekly-release-strategy.md +++ b/docs/internal/weekly-release-strategy.md @@ -36,7 +36,9 @@ owner, and deployment approver can synchronize on a predictable window. artifact to a stable, production-like RC environment. The `ironclaw-` namespace matches the tag-only cargo-dist publisher in `.github/workflows/ironclaw-release.yml`; a tag without that namespace never - invokes the publisher. + invokes the publisher. The release owner creates that tag with the + `Cut Ironclaw Release` workflow, supplying the exact approved commit SHA and + matching Cargo version. 4. Freeze the release branch after the cut. Do not add features, merge `main` into it, or rebase it. The approximately 30 PRs per day that continue landing on `main` are for the next release. diff --git a/scripts/ci/cut_ironclaw_release.py b/scripts/ci/cut_ironclaw_release.py new file mode 100644 index 0000000000..9b294e44f8 --- /dev/null +++ b/scripts/ci/cut_ironclaw_release.py @@ -0,0 +1,188 @@ +"""Create the cargo-dist tag for an explicitly approved Ironclaw commit.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +from collections.abc import Callable +from pathlib import Path + +import tomllib + +NUMERIC_IDENTIFIER = r"(?:0|[1-9][0-9]*)" +PRERELEASE_IDENTIFIER = r"(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)" +# This intentionally excludes Cargo build metadata: the same release publishes a +# Docker image, whose tag grammar does not permit '+'. +VERSION_PATTERN = re.compile( + rf"{NUMERIC_IDENTIFIER}\.{NUMERIC_IDENTIFIER}\.{NUMERIC_IDENTIFIER}" + rf"(?:-{PRERELEASE_IDENTIFIER}(?:\.{PRERELEASE_IDENTIFIER})*)?" +) +SHA_PATTERN = re.compile(r"[0-9a-f]{40}") +MAX_ANNOTATED_TAG_DEPTH = 8 + + +class ReleaseTagError(RuntimeError): + """The requested immutable release tag is unsafe or invalid.""" + + +def ensure_release_tag( + *, + requested_version: str, + requested_sha: str, + manifest_version: str, + checked_out_sha: str, + get_tag_target: Callable[[str], str | None], + create_tag: Callable[[str, str], None], +) -> str: + """Validate release identity and create its tag exactly once.""" + if VERSION_PATTERN.fullmatch(requested_version) is None: + raise ReleaseTagError(f"invalid release version: {requested_version!r}") + if SHA_PATTERN.fullmatch(requested_sha) is None: + raise ReleaseTagError("commit_sha must be a full lowercase commit SHA") + if checked_out_sha != requested_sha: + raise ReleaseTagError( + f"checked out {checked_out_sha}, expected approved commit {requested_sha}" + ) + if manifest_version != requested_version: + raise ReleaseTagError( + f"approved commit declares version {manifest_version}, " + f"not requested version {requested_version}" + ) + + tag = f"ironclaw-v{requested_version}" + existing_target = get_tag_target(tag) + if existing_target is not None: + if existing_target != requested_sha: + raise ReleaseTagError( + f"{tag} already points to {existing_target}, not {requested_sha}" + ) + return f"{tag} already points to approved commit {requested_sha}" + + try: + create_tag(tag, requested_sha) + except ReleaseTagError: + # A retried or concurrently dispatched run is safe only when it created + # the exact same immutable mapping. + if get_tag_target(tag) != requested_sha: + raise + return f"{tag} was concurrently created at approved commit {requested_sha}" + return f"created {tag} at approved commit {requested_sha}" + + +class GitHubTags: + def __init__(self, repository: str) -> None: + self.repository = repository + + def get_target(self, tag: str) -> str | None: + result = subprocess.run( + ["gh", "api", f"repos/{self.repository}/git/ref/tags/{tag}"], + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + if "HTTP 404" in result.stderr: + return None + raise ReleaseTagError(f"failed to read {tag}: {result.stderr.strip()}") + target = json.loads(result.stdout)["object"] + + for depth in range(MAX_ANNOTATED_TAG_DEPTH + 1): + object_type = str(target["type"]) + object_sha = str(target["sha"]) + if object_type == "commit": + return object_sha + if object_type != "tag": + raise ReleaseTagError( + f"{tag} resolves to unsupported Git object type {object_type!r}" + ) + if depth == MAX_ANNOTATED_TAG_DEPTH: + break + + result = subprocess.run( + [ + "gh", + "api", + f"repos/{self.repository}/git/tags/{object_sha}", + ], + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise ReleaseTagError( + f"failed to resolve annotated {tag}: {result.stderr.strip()}" + ) + target = json.loads(result.stdout)["object"] + + raise ReleaseTagError(f"{tag} exceeds the annotated-tag resolution depth limit") + + def create(self, tag: str, commit_sha: str) -> None: + result = subprocess.run( + [ + "gh", + "api", + "--method", + "POST", + f"repos/{self.repository}/git/refs", + "-f", + f"ref=refs/tags/{tag}", + "-f", + f"sha={commit_sha}", + ], + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise ReleaseTagError(f"failed to create {tag}: {result.stderr.strip()}") + + +def _checked_out_sha(candidate_root: Path) -> str: + return subprocess.run( + ["git", "rev-parse", "HEAD^{commit}"], + cwd=candidate_root, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + + +def _manifest_version(candidate_root: Path) -> str: + manifest = candidate_root / "crates/ironclaw_reborn_cli/Cargo.toml" + with manifest.open("rb") as manifest_file: + return str(tomllib.load(manifest_file)["package"]["version"]) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--version", required=True) + parser.add_argument("--commit-sha", required=True) + parser.add_argument("--candidate-root", required=True, type=Path) + parser.add_argument("--repository", required=True) + args = parser.parse_args() + + tags = GitHubTags(args.repository) + message = ensure_release_tag( + requested_version=args.version, + requested_sha=args.commit_sha, + manifest_version=_manifest_version(args.candidate_root), + checked_out_sha=_checked_out_sha(args.candidate_root), + get_tag_target=tags.get_target, + create_tag=tags.create, + ) + print(message) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except ( + ReleaseTagError, + subprocess.CalledProcessError, + KeyError, + ValueError, + ) as error: + raise SystemExit(f"error: {error}") from error diff --git a/scripts/ci/test_cut_ironclaw_release.py b/scripts/ci/test_cut_ironclaw_release.py new file mode 100644 index 0000000000..b64fde9895 --- /dev/null +++ b/scripts/ci/test_cut_ironclaw_release.py @@ -0,0 +1,230 @@ +"""Behavioral tests for immutable Ironclaw release tag creation.""" + +from __future__ import annotations + +import importlib.util +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parents[2] +MODULE_PATH = ROOT / "scripts/ci/cut_ironclaw_release.py" +SPEC = importlib.util.spec_from_file_location("cut_ironclaw_release", MODULE_PATH) +assert SPEC is not None and SPEC.loader is not None +release = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = release +SPEC.loader.exec_module(release) + +VERSION = "1.1.0-rc.1" +SHA = "a" * 40 + + +class ReleaseTagTests(unittest.TestCase): + def run_release( + self, *, targets: list[str | None], create_error: bool = False + ) -> tuple[str, list[tuple[str, str]]]: + lookups = iter(targets) + created: list[tuple[str, str]] = [] + + def create(tag: str, commit_sha: str) -> None: + created.append((tag, commit_sha)) + if create_error: + raise release.ReleaseTagError("create failed") + + message = release.ensure_release_tag( + requested_version=VERSION, + requested_sha=SHA, + manifest_version=VERSION, + checked_out_sha=SHA, + get_tag_target=lambda _tag: next(lookups), + create_tag=create, + ) + return message, created + + def test_creates_tag_for_exact_version_and_commit(self) -> None: + message, created = self.run_release(targets=[None]) + self.assertEqual(created, [(f"ironclaw-v{VERSION}", SHA)]) + self.assertIn("created", message) + + def test_existing_tag_is_idempotent_only_at_approved_commit(self) -> None: + message, created = self.run_release(targets=[SHA]) + self.assertEqual(created, []) + self.assertIn("already points", message) + + with self.assertRaisesRegex(release.ReleaseTagError, "already points"): + self.run_release(targets=["b" * 40]) + + def test_create_race_is_safe_only_at_approved_commit(self) -> None: + message, created = self.run_release(targets=[None, SHA], create_error=True) + self.assertEqual(created, [(f"ironclaw-v{VERSION}", SHA)]) + self.assertIn("concurrently created", message) + + with self.assertRaisesRegex(release.ReleaseTagError, "create failed"): + self.run_release(targets=[None, "b" * 40], create_error=True) + + def test_rejects_mismatched_checkout_and_manifest(self) -> None: + for field, value, message in ( + ("checked_out_sha", "b" * 40, "checked out"), + ("manifest_version", "1.1.0-rc.2", "declares version"), + ): + created: list[tuple[str, str]] = [] + arguments = { + "requested_version": VERSION, + "requested_sha": SHA, + "manifest_version": VERSION, + "checked_out_sha": SHA, + "get_tag_target": lambda _tag: None, + "create_tag": lambda tag, sha, calls=created: calls.append((tag, sha)), + } + arguments[field] = value + with ( + self.subTest(field=field), + self.assertRaisesRegex(release.ReleaseTagError, message), + ): + release.ensure_release_tag(**arguments) + self.assertEqual(created, []) + + def test_rejects_ambiguous_release_identity(self) -> None: + cases = ( + {"requested_version": "v1.1.0", "requested_sha": SHA}, + {"requested_version": "01.1.0", "requested_sha": SHA}, + {"requested_version": "1.01.0", "requested_sha": SHA}, + {"requested_version": "1.1.01", "requested_sha": SHA}, + {"requested_version": "1.1.0-01", "requested_sha": SHA}, + {"requested_version": "1.1.0-rc..1", "requested_sha": SHA}, + # Docker release tags cannot contain Cargo build metadata. + {"requested_version": "1.1.0+build.7", "requested_sha": SHA}, + {"requested_version": VERSION, "requested_sha": "abc123"}, + ) + for overrides in cases: + created: list[tuple[str, str]] = [] + arguments = { + "requested_version": VERSION, + "requested_sha": SHA, + "manifest_version": VERSION, + "checked_out_sha": SHA, + "get_tag_target": lambda _tag: None, + "create_tag": lambda tag, sha, calls=created: calls.append((tag, sha)), + } + arguments.update(overrides) + if "requested_version" in overrides: + arguments["manifest_version"] = overrides["requested_version"] + with ( + self.subTest(overrides=overrides), + self.assertRaisesRegex(release.ReleaseTagError, "invalid|commit_sha"), + ): + release.ensure_release_tag(**arguments) + self.assertEqual(created, []) + + def test_accepts_valid_semver_prerelease_identifiers(self) -> None: + for version in ("1.1.0-0", "1.1.0-rc.1", "1.1.0-01a"): + with self.subTest(version=version): + created: list[tuple[str, str]] = [] + release.ensure_release_tag( + requested_version=version, + requested_sha=SHA, + manifest_version=version, + checked_out_sha=SHA, + get_tag_target=lambda _tag: None, + create_tag=lambda tag, sha, calls=created: calls.append((tag, sha)), + ) + self.assertEqual(created, [(f"ironclaw-v{version}", SHA)]) + + def test_annotated_tag_is_resolved_to_its_commit(self) -> None: + tag_object_sha = "b" * 40 + responses = ( + mock.Mock( + returncode=0, + stdout=(f'{{"object":{{"type":"tag","sha":"{tag_object_sha}"}}}}'), + stderr="", + ), + mock.Mock( + returncode=0, + stdout=(f'{{"object":{{"type":"commit","sha":"{SHA}"}}}}'), + stderr="", + ), + ) + with mock.patch.object(release.subprocess, "run", side_effect=responses) as run: + target = release.GitHubTags("nearai/ironclaw").get_target("ironclaw-v1.0.0") + + self.assertEqual(target, SHA) + self.assertIn(f"git/tags/{tag_object_sha}", run.call_args_list[1].args[0][2]) + + def test_accepts_exact_annotated_tag_resolution_limit(self) -> None: + responses = [ + mock.Mock( + returncode=0, + stdout=f'{{"object":{{"type":"tag","sha":"{index:x}{"0" * 39}"}}}}', + stderr="", + ) + for index in range(1, release.MAX_ANNOTATED_TAG_DEPTH + 1) + ] + responses.append( + mock.Mock( + returncode=0, + stdout=f'{{"object":{{"type":"commit","sha":"{SHA}"}}}}', + stderr="", + ) + ) + + with mock.patch.object(release.subprocess, "run", side_effect=responses): + target = release.GitHubTags("nearai/ironclaw").get_target("ironclaw-v1.0.0") + + self.assertEqual(target, SHA) + + def test_rejects_annotated_tag_beyond_resolution_limit(self) -> None: + responses = [ + mock.Mock( + returncode=0, + stdout=f'{{"object":{{"type":"tag","sha":"{index:x}{"0" * 39}"}}}}', + stderr="", + ) + for index in range(1, release.MAX_ANNOTATED_TAG_DEPTH + 2) + ] + + with ( + mock.patch.object(release.subprocess, "run", side_effect=responses) as run, + self.assertRaisesRegex(release.ReleaseTagError, "resolution depth"), + ): + release.GitHubTags("nearai/ironclaw").get_target("ironclaw-v1.0.0") + + self.assertEqual(run.call_count, release.MAX_ANNOTATED_TAG_DEPTH + 1) + + def test_release_tooling_is_pinned_to_default_branch_dispatch(self) -> None: + workflow = (ROOT / ".github/workflows/cut-ironclaw-release.yml").read_text( + encoding="utf-8" + ) + self.assertIn( + "if: github.ref == format('refs/heads/{0}', " + "github.event.repository.default_branch)", + workflow, + ) + self.assertIn("ref: ${{ github.sha }}", workflow) + self.assertIn( + "python3 release-tools/scripts/ci/cut_ironclaw_release.py", workflow + ) + self.assertNotIn("candidate/scripts/ci/cut_ironclaw_release.py", workflow) + + def test_candidate_metadata_comes_from_supplied_checkout(self) -> None: + with tempfile.TemporaryDirectory() as directory: + candidate_root = Path(directory) + manifest = candidate_root / "crates/ironclaw_reborn_cli/Cargo.toml" + manifest.parent.mkdir(parents=True) + manifest.write_text( + f'[package]\nname = "ironclaw"\nversion = "{VERSION}"\n', + encoding="utf-8", + ) + self.assertEqual(release._manifest_version(candidate_root), VERSION) + + completed = mock.Mock(stdout=f"{SHA}\n") + with mock.patch.object( + release.subprocess, "run", return_value=completed + ) as run: + self.assertEqual(release._checked_out_sha(candidate_root), SHA) + self.assertEqual(run.call_args.kwargs["cwd"], candidate_root) + + +if __name__ == "__main__": + unittest.main()