mirror of
https://github.com/nearai/ironclaw.git
synced 2026-09-03 08:06:01 +08:00
Merge origin/main into the consolidated WS3/WS4 branch
This commit is contained in:
62
.github/workflows/cut-ironclaw-release.yml
vendored
Normal file
62
.github/workflows/cut-ironclaw-release.yml
vendored
Normal file
@@ -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"
|
||||
@@ -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.
|
||||
|
||||
188
scripts/ci/cut_ironclaw_release.py
Normal file
188
scripts/ci/cut_ironclaw_release.py
Normal file
@@ -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
|
||||
230
scripts/ci/test_cut_ironclaw_release.py
Normal file
230
scripts/ci/test_cut_ironclaw_release.py
Normal file
@@ -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()
|
||||
Reference in New Issue
Block a user