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
41 changes: 25 additions & 16 deletions .github/actions/release-summary/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -103,20 +112,20 @@ so two runs never race to post.

<!-- AUTO-DOC-INPUT:START - Do not remove or modify this section -->

| INPUT | TYPE | REQUIRED | DEFAULT | DESCRIPTION |
|-------------------|--------|----------|------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| anthropic-api-key | string | false | | Anthropic API key. Empty skips the <br>summary with a warning. |
| dry-run | string | false | `"false"` | Collect, summarize and render into the <br>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 <br>pull requests through GraphQL. |
| linear-workspace | string | false | `"loft"` | Linear workspace slug used to link <br>issue ids. |
| previous-tag | string | false | | Tag to compare against. Leave empty <br>to use the nearest earlier v* <br>tag that is not a -next <br>tag. |
| product | string | true | | Product name shown in the summary, <br>e.g. vCluster Platform. |
| repository | string | true | | Repository the release belongs to (owner/name). <br>The checkout must be this repository <br>with full history and tags. |
| slack-bot-token | string | false | | Slack bot token with chat:write and <br>channels:history (groups:history for a private channel) in the banner channel. <br>chat:write also lets a re-run edit <br>the summary the bot posted earlier. <br>Empty skips the post with a <br>warning. |
| slack-channel-id | string | false | | Slack channel id the banner was <br>posted to. |
| thread-ts | string | false | | Message ts of the release banner, <br>or its Slack message link (Copy link); <br>the summary is posted as a <br>reply to it. |
| version | string | true | | Release tag to summarize. Must exist <br>in the checkout. |
| INPUT | TYPE | REQUIRED | DEFAULT | DESCRIPTION |
|-------------------|--------|----------|------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| anthropic-api-key | string | false | | Anthropic API key. Empty skips the <br>summary with a warning. |
| dry-run | string | false | `"false"` | Collect, summarize and render into the <br>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 <br>pull requests through GraphQL. |
| linear-workspace | string | false | `"loft"` | Linear workspace slug used to link <br>issue ids. |
| previous-tag | string | false | | Tag to compare against. Leave empty <br>to derive it: a stable tag <br>(no prerelease part, or -patch / -patch.N) compares with the highest earlier <br>stable tag on any branch, a <br>pre-release with the nearest earlier v* <br>tag that is not a -next <br>tag. |
| product | string | true | | Product name shown in the summary, <br>e.g. vCluster Platform. |
| repository | string | true | | Repository the release belongs to (owner/name). <br>The checkout must be this repository <br>with full history and tags. |
| slack-bot-token | string | false | | Slack bot token with chat:write and <br>channels:history (groups:history for a private channel) in the banner channel. <br>chat:write also lets a re-run edit <br>the summary the bot posted earlier. <br>Empty skips the post with a <br>warning. |
| slack-channel-id | string | false | | Slack channel id the banner was <br>posted to. |
| thread-ts | string | false | | Message ts of the release banner, <br>or its Slack message link (Copy link); <br>the summary is posted as a <br>reply to it. |
| version | string | true | | Release tag to summarize. Must exist <br>in the checkout. |

<!-- AUTO-DOC-INPUT:END -->

Expand Down
2 changes: 1 addition & 1 deletion .github/actions/release-summary/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
51 changes: 46 additions & 5 deletions .github/actions/release-summary/src/collect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}}")
Expand All @@ -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
Expand Down
Loading
Loading