diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e59d64364..bfc23a3ea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -130,7 +130,9 @@ jobs: pattern: release-asset-* path: ${{ runner.temp }}/assets merge-multiple: true - continue-on-error: true + + - name: verify required release asset + run: test -s "$RUNNER_TEMP/assets/OdrCoreObjC.xcframework.zip" # Whatever the release has to record about itself is written into the tree # here; `stamp` commits all of it as one commit. diff --git a/AGENTS.md b/AGENTS.md index d444d1f47..1f48a5586 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -224,8 +224,8 @@ Both run by hand, and `--dry-run` mutates nothing. - To patch an older line, branch off the tag (`git branch release/v6.1.X v6.1.0`) and dispatch against that. A `feat:` there bumps the minor to a number main may have shipped, so `release.py version` refuses a version that - is already tagged. Pass `--version` when you mean it. That changelog is not - merged back. + is already tagged, including an explicit override. Use `--version` to select + an unused version. That changelog is not merged back. - A human publishes the draft. GitHub creates the tag at publish. A release created by `GITHUB_TOKEN` raises no `release: published` event, and that event starts the conan, maven and android workflows. @@ -238,7 +238,8 @@ Both run by hand, and `--dry-run` mutates nothing. `release-asset-*`. - `release_status.yml` waits for the publish workflows and fails if one failed or never started. `EXPECTED` in `scripts/release_status.py` lists the - destinations. + destinations. Status matches the release tag and commit; use GitHub's rerun + action to retry a failed publish run. Manual dispatches are not status evidence. ## Specs diff --git a/scripts/release.py b/scripts/release.py index 8f61a6d55..76e852a6b 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -89,8 +89,13 @@ def cliff(*arguments: str, capture: bool = False) -> str: def release_version(value: str) -> str: """A version tag safe to embed in package metadata and workflow outputs.""" suffix = r"[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*" - pattern = rf"v[0-9]+\.[0-9]+\.[0-9]+(?:-{suffix})?(?:\+{suffix})?" - if not re.fullmatch(pattern, value): + number = r"(?:0|[1-9][0-9]*)" + pattern = rf"v{number}\.{number}\.{number}(?:-(?P
{suffix}))?(?:\+{suffix})?"
+ match = re.fullmatch(pattern, value)
+ if not match or any(
+ part.isdigit() and len(part) > 1 and part.startswith("0")
+ for part in (match.group("pre") or "").split(".")
+ ):
raise argparse.ArgumentTypeError(
"expected vMAJOR.MINOR.PATCH with optional prerelease/build suffixes"
)
@@ -111,11 +116,9 @@ def command_version(arguments: argparse.Namespace) -> None:
cwd=REPO_ROOT,
capture_output=True,
).returncode == 0
- if tagged and not arguments.version:
+ if tagged:
raise SystemExit(
- f"the commits say {version}, but {version} is already tagged — a "
- f"maintenance line has bumped into a version the mainline used. "
- f"Re-run with an explicit --version."
+ f"{version} is already tagged — select an unused --version"
)
print(version)
diff --git a/scripts/release_status.py b/scripts/release_status.py
index 7d02b4a54..a526b0778 100755
--- a/scripts/release_status.py
+++ b/scripts/release_status.py
@@ -19,6 +19,7 @@
import subprocess
import sys
import time
+from urllib.parse import quote
# workflow `name:` -> what publishing it means; not listed is not watched
EXPECTED = {
@@ -57,19 +58,21 @@ def repository() -> str:
def release_commit(tag: str) -> str:
- return gh("api", f"repos/{repository()}/git/ref/tags/{tag}", "--jq",
- ".object.sha")
+ ref = quote(f"tags/{tag}", safe="")
+ return gh("api", f"repos/{repository()}/commits/{ref}", "--jq", ".sha")
-def runs_for(commit: str, self_run_id: str | None) -> dict[str, dict]:
+def runs_for(commit: str, self_run_id: str | None, tag: str) -> dict[str, dict]:
"""Latest release-triggered run per workflow — a re-run replaces the failure."""
- payload = json.loads(gh(
- "api", "-X", "GET", f"repos/{repository()}/actions/runs",
+ pages = json.loads(gh(
+ "api", "--paginate", "--slurp", "-X", "GET", f"repos/{repository()}/actions/runs",
"-f", "event=release", "-f", f"head_sha={commit}", "-f", "per_page=100",
))
latest: dict[str, dict] = {}
- for run in payload.get("workflow_runs", []):
+ for run in (run for page in pages for run in page.get("workflow_runs", [])):
+ if run["head_branch"] != tag:
+ continue
if self_run_id and str(run["id"]) == str(self_run_id):
continue
name = run["name"]
@@ -106,7 +109,7 @@ def report(tag: str, runs: dict[str, dict]) -> tuple[str, bool]:
def amend_release_notes(tag: str, block: str, *, dry_run: bool) -> None:
"""Replace the status block in the release body, or append one."""
- body = gh("api", f"repos/{repository()}/releases/tags/{tag}", "--jq", ".body")
+ body = gh("api", f"repos/{repository()}/releases/tags/{quote(tag, safe='')}", "--jq", ".body")
if BEGIN in body and END in body:
head, _, rest = body.partition(BEGIN)
@@ -144,7 +147,7 @@ def main() -> None:
deadline = time.monotonic() + arguments.timeout_minutes * 60
while True:
- runs = runs_for(commit, arguments.self_run_id)
+ runs = runs_for(commit, arguments.self_run_id, arguments.tag)
pending = [
name for name in EXPECTED
if name in runs and runs[name]["status"] != "completed"
diff --git a/scripts/tests/test_release.py b/scripts/tests/test_release.py
index 664cb693c..e7206c475 100644
--- a/scripts/tests/test_release.py
+++ b/scripts/tests/test_release.py
@@ -1,6 +1,7 @@
import argparse
import sys
import unittest
+from unittest import mock
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
@@ -9,15 +10,21 @@
class ReleaseVersionTest(unittest.TestCase):
def test_package_versions_are_preserved(self):
- for value in ("v7.4.1", "v8.0.0-rc.1", "v8.0.0-rc.1+build.2"):
+ for value in ("v7.4.1", "v8.0.0-rc.1", "v8.0.0-rc.1+build.2", "v0.0.0-0+001", "v1.2.3-01a"):
with self.subTest(value=value):
self.assertEqual(release.release_version(value), value)
def test_invalid_versions_cannot_reach_metadata_or_workflow_outputs(self):
for value in (
- "", "7.4.1", "v7.4", "v7.4.1-.", "v7.4.1\nversion=v0.0.0",
+ "", "7.4.1", "v7.4", "v7.4.1-.", "v07.4.1", "v7.04.1",
+ "v7.4.01", "v7.4.1-01", "v7.4.1-rc.01", "v7.4.1\nversion=v0.0.0",
"v7.4.1 --asset x", "v7.4.1$(touch marker)",
):
with self.subTest(value=value):
with self.assertRaises(argparse.ArgumentTypeError):
release.release_version(value)
+
+ def test_explicit_version_cannot_reuse_an_existing_tag(self):
+ with mock.patch.object(release.subprocess, "run", return_value=argparse.Namespace(returncode=0)):
+ with self.assertRaises(SystemExit):
+ release.command_version(argparse.Namespace(version="v7.4.1"))
diff --git a/scripts/tests/test_release_status.py b/scripts/tests/test_release_status.py
new file mode 100644
index 000000000..7a5e97a1d
--- /dev/null
+++ b/scripts/tests/test_release_status.py
@@ -0,0 +1,32 @@
+import json
+import sys
+import unittest
+from pathlib import Path
+from unittest import mock
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+import release_status
+
+
+class ReleaseStatusTest(unittest.TestCase):
+ def test_tag_resolves_to_commit_not_annotated_tag_object(self):
+ def gh(*args):
+ return "commit" if "/commits/tags%2F" in args[1] else "tag-object"
+ with mock.patch.object(release_status, "repository", return_value="owner/repo"), \
+ mock.patch.object(release_status, "gh", side_effect=gh):
+ self.assertEqual(release_status.release_commit("v1.2.3"), "commit")
+
+ def test_runs_are_paginated_and_scoped_to_the_release_tag(self):
+ def run(number, tag="v1.2.3", name="python"):
+ return {"id": number, "run_number": number, "name": name,
+ "head_branch": tag, "event": "release", "head_sha": "commit"}
+ pages = [{"workflow_runs": [run(100, "v1.2.4"), run(3), run(4)]},
+ {"workflow_runs": [run(1), run(2, name="conan")]}]
+ def gh(*args):
+ return json.dumps(pages if "--slurp" in args else pages[0])
+ with mock.patch.object(release_status, "repository", return_value="owner/repo"), \
+ mock.patch.object(release_status, "gh", side_effect=gh) as call:
+ runs = release_status.runs_for("commit", "4", "v1.2.3")
+ self.assertEqual({name: run["id"] for name, run in runs.items()},
+ {"python": 3, "conan": 2})
+ self.assertIn("--paginate", call.call_args.args)