Skip to content
Open
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
2 changes: 1 addition & 1 deletion .github/workflows/regular-release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ jobs:
draft: false
prerelease: false
body: |
This is CBMC version ${{ env.CBMC_VERSION }}.
This is CBMC version ${{ env.CBMC_VERSION }}. See [CHANGELOG](CHANGELOG) for what changed in this release.

## MacOS

Expand Down
18 changes: 17 additions & 1 deletion doc/ADR/release_process.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
\page release-process Release Process

**Date**: 2020-10-08
**Updated**: 2023-03-29
**Updated**: 2026-03-30
**Author**: Fotis Koutoulakis, fotis.koutoulakis@diffblue.com
**Domain**: Release & Packaging

Expand Down Expand Up @@ -39,6 +39,22 @@ The current process we follow through to make a new release is the following:
publish` step needs to be a member of the [diffblue/diffblue-opensource](https://github.com/orgs/diffblue/teams/diffblue-opensource)
team.

### Changelog / Release Notes

Before pushing the release tag, the release manager should update the
`CHANGELOG` file at the repository root. A draft can be generated using:

scripts/draft_release_notes.py cbmc-<version>

where `cbmc-<version>` is the tag to be created (it need not exist yet;
the script will use the latest existing tag as the base). If auto-detection
picks the wrong base, pass `--previous cbmc-<old-version>` explicitly.

This calls the GitHub release-notes API to produce a PR list in the same
format already used in `CHANGELOG`, and prepends a draft summary paragraph.
The summary is heuristic and must be reviewed and edited before committing.
See `scripts/draft_release_notes.py --help` for options.
Comment thread
tautschnig marked this conversation as resolved.

At this point, the rest of the process is automated, so we don't need to do
anything more, but the process is described below for reference:

Expand Down
190 changes: 190 additions & 0 deletions scripts/draft_release_notes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""
Generate draft release notes for a CBMC release.

Calls the GitHub release-notes generation endpoint (the same one behind
the "Generate release notes" button in the GitHub UI) and prepends a
draft summary paragraph derived from the PR titles.

The tag need not exist yet; when auto-detecting the previous tag the
script falls back to the latest existing tag.

Usage:
scripts/draft_release_notes.py cbmc-6.8.0
scripts/draft_release_notes.py cbmc-6.8.0 --previous cbmc-6.7.1
"""

import json
import re
import subprocess
import sys
import textwrap


def gh_generate_notes(tag: str, previous: str, repo: str) -> str:
"""Call the GitHub generate-notes API via `gh`."""
cmd = [
"gh", "api", f"repos/{repo}/releases/generate-notes",
"-f", f"tag_name={tag}",
"-f", f"previous_tag_name={previous}",
]
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
return json.loads(result.stdout)["body"]


def previous_tag(tag: str, repo: str) -> str:
"""Find the tag immediately before *tag* using `gh`."""
cmd = [
"gh", "api", f"repos/{repo}/tags",
"--paginate", "-q", ".[].name",
]
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
tags = [t for t in result.stdout.splitlines() if t.startswith("cbmc-")]

def version_key(t):
parts = t.split("-", 1)[1].split(".")
nums = []
for p in parts:
m = re.match(r"(\d+)", p)
nums.append(int(m.group(1)) if m else 0)
return nums

tags.sort(key=version_key, reverse=True)
for i, t in enumerate(tags):
if t == tag and i + 1 < len(tags):
return tags[i + 1]
# The new tag may not exist yet; fall back to the latest existing tag.
if tags:
return tags[0]
sys.exit(f"Cannot find a tag before {tag}")


def version_from_tag(tag: str) -> str:
return tag.split("-", 1)[1]


# Patterns for changes that are NOT user-facing
_SKIP = re.compile(
r"(?i)"
r"bump |dependabot|"
r"\bCI\b|ci:|ci job|GitHub Action|runner|"
r"Compile Java regression|"
r"CODEOWNERS|"
r"clang-format|"
r"Release CBMC|"
r"Merge pull request"
)

# Patterns that suggest a user-visible feature (not just a fix/refactor)
_FEATURE = re.compile(
r"(?i)"
r"\badd\b|"
r"\bimplement\b|"
r"\bintroduce\b|"
r"\bsupport\b|"
r"\bnew\b|"
r"\benable\b"
)


def draft_summary(notes: str, version: str) -> str:
"""Build a one-paragraph draft summary from the generated notes.

Strategy: pick the top user-visible feature PRs and mention them.
This is a *draft* — the release manager should edit it.
"""
# Extract PR lines: "* <title> by @author in <url>"
pr_lines = [
line.strip()
for line in notes.splitlines()
if line.strip().startswith("* ")
]

# Filter to user-facing changes
visible = [l for l in pr_lines if not _SKIP.search(l)]
features = [l for l in visible if _FEATURE.search(l)]
fixes = [l for l in visible if l not in features]

# Extract short title + PR number for highlights
def extract(line: str):
m = re.match(r"\*\s+(.+?)\s+by\s+@", line)
title = m.group(1) if m else line.lstrip("* ")
m2 = re.search(r"/pull/(\d+)", line)
pr = m2.group(1) if m2 else None
return title, pr

highlights = []
for line in (features or visible)[:3]:
title, pr = extract(line)
ref = f" (via #{pr})" if pr else ""
highlights.append(f"{title}{ref}")

n_fixes = len(fixes)
fix_note = ""
if n_fixes:
fix_note = (
f" The release also includes {n_fixes} other change"
f"{'s' if n_fixes != 1 else ''}."
)
Comment thread
tautschnig marked this conversation as resolved.

if not highlights:
return f"<!-- TODO: write a summary for CBMC {version} -->\n"

if len(highlights) == 1:
body = highlights[0]
elif len(highlights) == 2:
body = f"{highlights[0]} and {highlights[1]}"
else:
body = f"{highlights[0]}, {highlights[1]}, and {highlights[2]}"

return (
f"<!-- DRAFT — please review and edit this summary -->\n"
f"This release includes {body}.{fix_note}\n"
)


def format_release_notes(notes: str, version: str) -> str:
"""Combine a header, draft summary, and the GitHub-generated body."""
raw = draft_summary(notes, version)
# Keep the HTML comment on its own line; only wrap the prose.
lines = raw.split("\n", 1)
if len(lines) == 2 and lines[0].startswith("<!--"):
summary = lines[0] + "\n" + textwrap.fill(lines[1], width=80)
else:
summary = textwrap.fill(raw, width=80)
return f"# CBMC {version}\n\n{summary}\n\n{notes}\n"


def main():
import argparse
p = argparse.ArgumentParser(
description="Generate draft CHANGELOG entry for a CBMC release"
)
p.add_argument("tag", help="Release tag, e.g. cbmc-6.8.0")
p.add_argument("--previous", help="Previous release tag (auto-detected)")
p.add_argument("--repo", default="diffblue/cbmc")
p.add_argument(
"-o", "--output",
help="Write to file instead of stdout",
)
args = p.parse_args()

prev = args.previous or previous_tag(args.tag, args.repo)
ver = version_from_tag(args.tag)

print(f"Generating notes for {args.tag} (since {prev})...",
file=sys.stderr)

notes = gh_generate_notes(args.tag, prev, args.repo)
output = format_release_notes(notes, ver)

if args.output:
with open(args.output, "w") as f:
f.write(output)
print(f"Written to {args.output}", file=sys.stderr)
else:
print(output)


if __name__ == "__main__":
main()
Loading