diff --git a/.github/actions/release-summary/README.md b/.github/actions/release-summary/README.md index 81161508..7ca1c8a6 100644 --- a/.github/actions/release-summary/README.md +++ b/.github/actions/release-summary/README.md @@ -13,12 +13,21 @@ any failure-notification job's `needs`. ## How it works -1. `src/collect.py` picks the comparison base (the nearest earlier `v*` tag - that is not a `-next` tag, or `previous-tag`), lists first-parent commits, +1. `src/collect.py` picks the comparison base, lists first-parent commits, and resolves each to its merged PR through GraphQL `associatedPullRequests`. Rebase-merged PRs carry no `(#N)` in their subjects, so the subject is only a fallback. It extracts Linear ids from closing keywords, Linear links, title prefixes and branch names. + + The base is `previous-tag` when given. Otherwise it depends on the tag: + + | Tag | Compared with | Example | + |---|---|---| + | Pre-release (`-alpha`, `-beta`, `-rc`) | the nearest earlier `v*` tag that is not a `-next` tag | `v5.0.0-alpha.3` against `v5.0.0-alpha.2` | + | Stable (no prerelease part, or `-patch` / `-patch.N`) | the highest earlier stable tag on any branch, skipping every pre-release | `v4.12.2` against `v4.12.1`, not its `v4.12.2-rc.7` | + + Stability follows the tag name, as in `linear-release-sync`, and a + `-patch.N` tag sorts right above the release it patches. 2. [`ai-step`](../ai-step/README.md) summarizes the PR descriptions, changed paths and diffstat into features, bug fixes, tech debt and tests, ordered by customer impact. The input travels through `input-file`, because PR @@ -103,20 +112,20 @@ so two runs never race to post. -| INPUT | TYPE | REQUIRED | DEFAULT | DESCRIPTION | -|-------------------|--------|----------|------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| anthropic-api-key | string | false | | Anthropic API key. Empty skips the
summary with a warning. | -| dry-run | string | false | `"false"` | Collect, summarize and render into the
job summary without posting to Slack. | -| effort | string | false | `"medium"` | ai-step effort level (low, medium, high). | -| github-token | string | true | | Token that can read the repository
pull requests through GraphQL. | -| linear-workspace | string | false | `"loft"` | Linear workspace slug used to link
issue ids. | -| previous-tag | string | false | | Tag to compare against. Leave empty
to use the nearest earlier v*
tag that is not a -next
tag. | -| product | string | true | | Product name shown in the summary,
e.g. vCluster Platform. | -| repository | string | true | | Repository the release belongs to (owner/name).
The checkout must be this repository
with full history and tags. | -| slack-bot-token | string | false | | Slack bot token with chat:write and
channels:history (groups:history for a private channel) in the banner channel.
chat:write also lets a re-run edit
the summary the bot posted earlier.
Empty skips the post with a
warning. | -| slack-channel-id | string | false | | Slack channel id the banner was
posted to. | -| thread-ts | string | false | | Message ts of the release banner,
or its Slack message link (Copy link);
the summary is posted as a
reply to it. | -| version | string | true | | Release tag to summarize. Must exist
in the checkout. | +| INPUT | TYPE | REQUIRED | DEFAULT | DESCRIPTION | +|-------------------|--------|----------|------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| anthropic-api-key | string | false | | Anthropic API key. Empty skips the
summary with a warning. | +| dry-run | string | false | `"false"` | Collect, summarize and render into the
job summary without posting to Slack. | +| effort | string | false | `"medium"` | ai-step effort level (low, medium, high). | +| github-token | string | true | | Token that can read the repository
pull requests through GraphQL. | +| linear-workspace | string | false | `"loft"` | Linear workspace slug used to link
issue ids. | +| previous-tag | string | false | | Tag to compare against. Leave empty
to derive it: a stable tag
(no prerelease part, or -patch / -patch.N) compares with the highest earlier
stable tag on any branch, a
pre-release with the nearest earlier v*
tag that is not a -next
tag. | +| product | string | true | | Product name shown in the summary,
e.g. vCluster Platform. | +| repository | string | true | | Repository the release belongs to (owner/name).
The checkout must be this repository
with full history and tags. | +| slack-bot-token | string | false | | Slack bot token with chat:write and
channels:history (groups:history for a private channel) in the banner channel.
chat:write also lets a re-run edit
the summary the bot posted earlier.
Empty skips the post with a
warning. | +| slack-channel-id | string | false | | Slack channel id the banner was
posted to. | +| thread-ts | string | false | | Message ts of the release banner,
or its Slack message link (Copy link);
the summary is posted as a
reply to it. | +| version | string | true | | Release tag to summarize. Must exist
in the checkout. | diff --git a/.github/actions/release-summary/action.yml b/.github/actions/release-summary/action.yml index 0cf027f9..7441634f 100644 --- a/.github/actions/release-summary/action.yml +++ b/.github/actions/release-summary/action.yml @@ -8,7 +8,7 @@ inputs: description: 'Release tag to summarize. Must exist in the checkout.' required: true previous-tag: - description: 'Tag to compare against. Leave empty to use the nearest earlier v* tag that is not a -next tag.' + description: 'Tag to compare against. Leave empty to derive it: a stable tag (no prerelease part, or -patch / -patch.N) compares with the highest earlier stable tag on any branch, a pre-release with the nearest earlier v* tag that is not a -next tag.' required: false default: '' repository: diff --git a/.github/actions/release-summary/src/collect.py b/.github/actions/release-summary/src/collect.py index 4eb08088..e817d49e 100644 --- a/.github/actions/release-summary/src/collect.py +++ b/.github/actions/release-summary/src/collect.py @@ -14,7 +14,9 @@ Env: VERSION release tag (required) - PREVIOUS_TAG optional override for the comparison base + PREVIOUS_TAG optional override for the comparison base; empty means + the previous stable tag for a stable VERSION, the + nearest earlier tag for a pre-release REPOSITORY owner/name GH_TOKEN token with read access to the repository's PRs HAS_ANTHROPIC_KEY "true" when an Anthropic key was passed @@ -44,7 +46,9 @@ TAG_MATCH = "v*" TAG_EXCLUDE = "*-next*" -COMMIT_LIMIT = 500 +# A stable range spans its whole line: loft-enterprise v4.12.4 to the commit +# of v5.0.0-alpha.5 is already 716 first-parent commits. +COMMIT_LIMIT = 1000 SLACK_TS = re.compile(r"\d{9,}\.\d{6}") SLACK_LINK = re.compile(r"/p(\d{10})(\d{6})(?:\D|$)") GRAPHQL_BATCH = 40 @@ -130,6 +134,40 @@ def semver_key(tag: str) -> tuple | None: return (int(major), int(minor), int(patch), 0 if pre else 1, ids) +def is_stable(tag: str) -> bool: + """Stable by tag name, as linear-release-sync's releaseIsStable decides + (DEVOPS-1006): no prerelease part, or the backport marker patch / patch.N. + A tag that is not semver is not stable.""" + m = SEMVER.fullmatch(tag) + if not m: + return False + pre = m.group(4) or "" + return pre == "" or pre == "patch" or pre.startswith("patch.") + + +def stable_key(tag: str) -> tuple: + """semver_key for a stable tag, except a patch sorts right above the + release it patches: v0.28.2-patch.1 is cut on top of v0.28.2, where + semver would put it below.""" + major, minor, patch, _, ids = semver_key(tag) + return (major, minor, patch, ids) + + +def previous_stable(version: str) -> str: + """The highest stable tag below version, on any branch. Pre-releases in + between are skipped, so a stable summary covers everything the line + shipped since the last stable; that tag can sit on another release + branch, out of reach of describe.""" + tags = [t for t in git("tag", "--list", TAG_MATCH).splitlines() + if not fnmatch.fnmatchcase(t, TAG_EXCLUDE) and is_stable(t)] + limit = stable_key(version) + below = [(key, t) for t in tags if (key := stable_key(t)) < limit] + if not below: + skip(f"no stable {TAG_MATCH} tag sorts below {version}, so there is " + "nothing to compare against; pass previous-tag to set one") + return max(below)[1] + + def release_tags_at(tag: str) -> list[str]: """TAG_MATCH tags, minus TAG_EXCLUDE, on the commit tag points at.""" out = git("tag", "--list", TAG_MATCH, "--points-at", f"refs/tags/{tag}^{{commit}}") @@ -147,14 +185,17 @@ def nearest_below(version: str, tags: list[str]) -> str: def resolve_previous_tag(version: str, override: str) -> str: - """The override when given, else the nearest non -next tag before version. - A release is cut from the commit of its last rc, so tags on version's own - commit come first; `describe version^` steps past all of them.""" + """The override when given. Else a stable version compares against the + previous stable tag, and a pre-release against the nearest non -next tag + before it: tags on version's own commit first, then `describe version^`, + which steps past all of them.""" if override: if not tag_exists(override): skip(f"previous tag '{override}' is not in the checkout; pass an " "existing tag or leave previous-tag empty to derive it") return override + if is_stable(version): + return previous_stable(version) shared = nearest_below(version, release_tags_at(version)) if shared: return shared diff --git a/.github/actions/release-summary/test/test_collect.py b/.github/actions/release-summary/test/test_collect.py index b7b3c2c9..7b3303ed 100644 --- a/.github/actions/release-summary/test/test_collect.py +++ b/.github/actions/release-summary/test/test_collect.py @@ -172,27 +172,109 @@ def test_first_release_skips(repo, env): assert "nothing to compare" in outputs(env)["reason"] -def test_previous_tag_prefers_the_rc_on_the_same_commit(tmp_path, monkeypatch): - """v4.12.2 is cut from the commit of v4.12.2-rc.7; describe v4.12.2^ would - skip it and answer rc.6, so the GA summary would repeat rc.7's changes.""" +@pytest.mark.parametrize("tags, version", [ + ([["v1.0.0-alpha.1"]], "v1.0.0-alpha.1"), + ([["v1.0.0-alpha.1"], ["v1.0.0-rc.1", "v1.0.0"]], "v1.0.0"), +]) +def test_first_of_its_kind_skips(tmp_path, monkeypatch, env, tags, version): + """A first pre-release has no earlier tag; a first stable has only + pre-releases below it.""" + history(tmp_path, monkeypatch, *tags) + with pytest.raises(SystemExit) as exc: + load().resolve_previous_tag(version, "") + assert exc.value.code == 0 + assert "nothing to compare" in outputs(env)["reason"] + + +def test_stable_skips_the_rc_on_the_same_commit(tmp_path, monkeypatch): + """v4.12.2 is cut from the commit of v4.12.2-rc.7. Comparing with that rc + gives an empty range and a zero-PR skip, so a stable tag compares with + the previous stable and covers every rc in between.""" history(tmp_path, monkeypatch, ["v4.12.1"], ["v4.12.2-rc.6"], ["v4.12.2-rc.7", "v4.12.2"]) - assert load().resolve_previous_tag("v4.12.2", "") == "v4.12.2-rc.7" + mod = load() + assert mod.resolve_previous_tag("v4.12.2", "") == "v4.12.1" + assert len(mod.list_commits("v4.12.1", "v4.12.2")) == 2 + + +def test_override_wins_over_both_rules(tmp_path, monkeypatch): + history(tmp_path, monkeypatch, ["v4.12.1"], ["v4.12.2-rc.6"], + ["v4.12.2-rc.7", "v4.12.2"]) + mod = load() + assert mod.resolve_previous_tag("v4.12.2", "v4.12.2-rc.6") == "v4.12.2-rc.6" + assert mod.resolve_previous_tag("v4.12.2-rc.7", "v4.12.1") == "v4.12.1" def test_previous_tag_same_commit_orders_numerically(tmp_path, monkeypatch): - """rc.10 beats rc.9 and a prerelease beats an older alpha, whatever the - order git lists them in.""" + """rc.9 beats beta.1 and alpha.2 below rc.10, whatever the order git + lists them in; the stable release on the commit compares with the + previous stable.""" history(tmp_path, monkeypatch, ["v1.0.0"], ["v1.1.0-alpha.2", "v1.1.0-rc.9", "v1.1.0-rc.10", "v1.1.0-beta.1", "v1.1.0"]) - assert load().resolve_previous_tag("v1.1.0", "") == "v1.1.0-rc.10" + mod = load() + assert mod.resolve_previous_tag("v1.1.0-rc.10", "") == "v1.1.0-rc.9" + assert mod.resolve_previous_tag("v1.1.0", "") == "v1.0.0" def test_previous_tag_same_commit_ignores_next_tags(tmp_path, monkeypatch): - """-next sorts above alpha, so only the exclusion keeps it out.""" + """-next sorts between alpha and rc, so only the exclusion keeps it out.""" history(tmp_path, monkeypatch, ["v1.0.0"], - ["v1.1.0-alpha.1", "v1.1.0-next.3", "v1.1.0"]) - assert load().resolve_previous_tag("v1.1.0", "") == "v1.1.0-alpha.1" + ["v1.1.0-alpha.1", "v1.1.0-next.3", "v1.1.0-rc.1"]) + assert load().resolve_previous_tag("v1.1.0-rc.1", "") == "v1.1.0-alpha.1" + + +def test_stable_finds_the_previous_stable_on_a_sibling_branch(tmp_path, monkeypatch): + """v5.0.0 on main follows v4.12.2 on release-4.12, which describe from + main cannot reach. A higher stable elsewhere never counts, and the + pre-release rule is unchanged.""" + r = history(tmp_path, monkeypatch, ["v4.12.0"]) + git(r, "checkout", "-q", "-b", "release-4.12") + for tag in ("v4.12.1", "v4.12.2"): + commit(r, tag) + git(r, "tag", tag) + git(r, "checkout", "-q", "main") + commit(r, "alpha") + git(r, "tag", "v5.0.0-alpha.1") + commit(r, "rc") + git(r, "tag", "v5.0.0-rc.1") + git(r, "tag", "v5.0.0") + mod = load() + assert git(r, "describe", "--tags", "--abbrev=0", "v5.0.0^") == "v5.0.0-alpha.1" + assert mod.resolve_previous_tag("v5.0.0", "") == "v4.12.2" + assert mod.resolve_previous_tag("v4.12.2", "") == "v4.12.1" + assert mod.resolve_previous_tag("v5.0.0-alpha.1", "") == "v4.12.0" + assert [s for _, s in mod.list_commits("v4.12.2", "v5.0.0")] == ["rc", "alpha"] + + +def test_stable_counts_a_patch_as_stable(tmp_path, monkeypatch): + """v0.28.2-patch.1 is cut on top of v0.28.2 (vcluster-pro), so it + compares with v0.28.2 and is itself the base for the next patch, though + semver sorts it below v0.28.2.""" + history(tmp_path, monkeypatch, ["v0.28.1"], ["v0.28.2-rc.1", "v0.28.2"], + ["v0.28.2-patch.1"], ["v0.28.2-patch.2"], ["v0.28.3-rc.1", "v0.28.3"]) + mod = load() + assert mod.resolve_previous_tag("v0.28.2-patch.1", "") == "v0.28.2" + assert mod.resolve_previous_tag("v0.28.2-patch.2", "") == "v0.28.2-patch.1" + assert mod.resolve_previous_tag("v0.28.3", "") == "v0.28.2-patch.2" + + +@pytest.mark.parametrize("tag, stable", [ + ("v0.34.4", True), + ("v0.28.2-patch.1", True), + ("v0.28.2-patch", True), + ("v0.34.4+build.1", True), + ("v0.35.0-rc.9", False), + ("v0.35.0-alpha.8", False), + ("v0.35.0-beta.2", False), + ("v0.35.0-dev.1", False), + ("v0.35.0-pre.1", False), + ("v0.35.0-next.0", False), + ("v0.35.0-patchset.1", False), + ("latest", False), +]) +def test_is_stable_matches_linear_release_sync(tag, stable): + """The cases of linear-release-sync TestReleaseIsStable (DEVOPS-1006).""" + assert load().is_stable(tag) is stable def test_previous_tag_ignores_a_higher_tag_on_the_same_commit(tmp_path, monkeypatch): @@ -203,23 +285,23 @@ def test_previous_tag_ignores_a_higher_tag_on_the_same_commit(tmp_path, monkeypa def test_previous_tag_falls_back_to_describe_without_a_shared_commit(tmp_path, monkeypatch): - history(tmp_path, monkeypatch, ["v1.0.0"], [], ["v1.1.0"]) - assert load().resolve_previous_tag("v1.1.0", "") == "v1.0.0" + history(tmp_path, monkeypatch, ["v1.1.0-alpha.1"], [], ["v1.1.0-alpha.2"]) + assert load().resolve_previous_tag("v1.1.0-alpha.2", "") == "v1.1.0-alpha.1" def test_previous_tag_prefers_the_release_over_its_rc_on_one_commit(tmp_path, monkeypatch): - """v4.12.1 follows a commit carrying v4.12.0 and v4.12.0-rc.12. describe - breaks that tie on tagger date, so an rc tagged later wins over the - release; the tagger dates here pin that.""" + """v4.12.1-rc.1 follows a commit carrying v4.12.0 and v4.12.0-rc.12. + describe breaks that tie on tagger date, so an rc tagged later wins over + the release; the tagger dates here pin that.""" r = history(tmp_path, monkeypatch, ["v4.12.0-rc.11"], []) for tag, date in (("v4.12.0", "2026-01-01T00:00:00"), ("v4.12.0-rc.12", "2026-01-02T00:00:00")): subprocess.run(["git", "-C", str(r), "tag", "-a", "-m", tag, tag], check=True, capture_output=True, env={**os.environ, "GIT_COMMITTER_DATE": date}) commit(r, "commit 2") - git(r, "tag", "v4.12.1") - assert git(r, "describe", "--tags", "--abbrev=0", "v4.12.1^") == "v4.12.0-rc.12" - assert load().resolve_previous_tag("v4.12.1", "") == "v4.12.0" + git(r, "tag", "v4.12.1-rc.1") + assert git(r, "describe", "--tags", "--abbrev=0", "v4.12.1-rc.1^") == "v4.12.0-rc.12" + assert load().resolve_previous_tag("v4.12.1-rc.1", "") == "v4.12.0" def test_semver_key_orders_by_precedence(): @@ -519,6 +601,22 @@ def fake_graphql(query, variables, token): assert len(calls) == 1 +def test_main_summarizes_a_stable_cut_from_its_last_rc(tmp_path, monkeypatch, env): + """Compared with rc.2 on its own commit, v1.1.0 would hold no PR and + skip; compared with v1.0.0 it covers both rcs.""" + history(tmp_path, monkeypatch, ["v1.0.0"], ["v1.1.0-rc.1"], ["v1.1.0-rc.2", "v1.1.0"]) + monkeypatch.setenv("VERSION", "v1.1.0") + mod = load() + mod.graphql = lambda q, v, t: {"repository": { + "c0": {"associatedPullRequests": {"nodes": [pr(21)]}}, + "c1": {"associatedPullRequests": {"nodes": [pr(20)]}}}} + mod.main() + o = outputs(env) + assert o["proceed"] == "true" + assert o["previous_tag"] == "v1.0.0" + assert o["pr_count"] == "2" + + def test_main_falls_back_to_subject_pr_number(repo, env): mod = load()