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()