From 45f428ac0d5ea0ed4fd4f0b7c032b30536fc06ea Mon Sep 17 00:00:00 2001 From: Piotr Zaniewski Date: Wed, 21 Oct 2026 14:08:43 +0200 Subject: [PATCH] fix(release-summary): compare a stable tag with the previous stable tag A stable tag is cut from its last rc's commit, so the tags-on-the-same- commit rule compared v4.12.2 with v4.12.2-rc.7. The range came back empty or near empty and collect.py skipped on zero merged PRs, so a stable banner in #product-releases got no useful summary. Denise asked for stable notes to cover everything since the previous stable tag, and pre-release notes to cover the previous pre-release, which they already do. A stable tag (no prerelease part, or patch / patch.N, matching linear-release-sync releaseIsStable from DEVOPS-1006) now compares with the highest earlier stable tag from the full tag list, since the previous stable can sit on another release branch out of reach of git describe (v5.0.0 on main follows v4.12.x on release-4.12). A -patch.N tag sorts right above the release it patches: in vcluster-pro v0.28.2-patch.1 is cut on top of v0.28.2, where semver order would put it below. Pre-releases and the previous-tag override keep their rules. COMMIT_LIMIT goes from 500 to 1000. 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, so at 500 the first v5.0.0 summary would drop its oldest work while the header said "since v4.12.4". Checked against loft-enterprise: v4.12.3 resolves to v4.12.2 (12 PRs, was v4.12.3-rc.2 with 5), v5.0.0-alpha.3 still resolves to v5.0.0-alpha.2 (39 PRs), and a v5.0.0 tag on alpha.5's commit resolves to v4.12.4 with 532 PRs, where main skipped with no commits. fit_budget drops every body for that range and still warns the input is over budget (464 KB against 400 KB); it proceeds, and stays inside the model context. Closes DEVOPS-1664 Claude-Session: https://claude.ai/code/session_017Fi5sA2RGopJjZnpWZfVxU --- .github/actions/release-summary/README.md | 41 +++--- .github/actions/release-summary/action.yml | 2 +- .../actions/release-summary/src/collect.py | 51 ++++++- .../release-summary/test/test_collect.py | 134 +++++++++++++++--- 4 files changed, 188 insertions(+), 40 deletions(-) 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()