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
4 changes: 3 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down
15 changes: 9 additions & 6 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<pre>{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"
)
Expand All @@ -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)
Expand Down
19 changes: 11 additions & 8 deletions scripts/release_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand Down
11 changes: 9 additions & 2 deletions scripts/tests/test_release.py
Original file line number Diff line number Diff line change
@@ -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]))
Expand All @@ -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"))
32 changes: 32 additions & 0 deletions scripts/tests/test_release_status.py
Original file line number Diff line number Diff line change
@@ -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)
Loading