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 the [CHANGELOG](https://github.com/diffblue/cbmc/blob/cbmc-${{ env.CBMC_VERSION }}/CHANGELOG) for what changed in this release.

## MacOS

Expand Down
28 changes: 23 additions & 5 deletions 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 All @@ -20,13 +20,31 @@ The current process we follow through to make a new release is the following:

(This needs to be pushed as a PR, and after it gets merged we move on to:)

2. Then we make a `git tag` out of that commit, and push it to github. The
2. Update the `CHANGELOG` file at the repository root before pushing the
release tag, so that the GitHub release that is auto-created on the tag
push (and the `CHANGELOG` link in its body) describes this release
accurately. A draft entry 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. The release
manager should then manually prepend the generated entry to the top of
`CHANGELOG` and commit it (conveniently as part of the version-bump PR in
step 1). See `scripts/draft_release_notes.py --help` for options.

3. Then we make a `git tag` out of that commit, and push it to github. The
tag needs to be of the form `cbmc-<version>` with version being a version
number of the form of `x.y.z`, with `x` denoting the major version, `y`
denoting the minor version, and `z` identifying the patch version (useful
for a hotfix or patch.)

3. Pushing the Rust crate, which is documented [here](https://doc.rust-lang.org/cargo/commands/cargo-publish.html)
4. Pushing the Rust crate, which is documented [here](https://doc.rust-lang.org/cargo/commands/cargo-publish.html)
but effectively entails logging in with an API token generated from
https://crates.io with `cargo login`, and then issuing `cargo publish`.

Expand All @@ -42,12 +60,12 @@ The current process we follow through to make a new release is the following:
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:

3. `.github/workflows/regular-release.yaml` gets triggered on the `push`
5. `.github/workflows/regular-release.yaml` gets triggered on the `push`
of the tag, and creates a Github release of the version that was
described in the tag pushed (so, tag `cbmc-5.15.20` is going to
create the release titled `cbmc-5.15.20` on the release page).

4. `.github/workflows/release-packages.yaml` gets triggered automatically
6. `.github/workflows/release-packages.yaml` gets triggered automatically
at the creation of the release, and its job is to build packages for
Windows, Ubuntu 18.04 and Ubuntu 20.04 (for now, we may support more
specific Ubuntu versions later) and attaches them (after it has finished
Expand Down
236 changes: 236 additions & 0 deletions scripts/draft_release_notes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
#!/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.

This is a CBMC-specific helper: the release-tag prefix and the "# CBMC"
heading are hard-coded for the diffblue/cbmc repository.

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
from dataclasses import dataclass

REPO = "diffblue/cbmc"


def run_gh(args):
"""Run a `gh` command and return its stdout.

Exits cleanly (rather than dumping a stack trace) when `gh` is missing or
the call fails, e.g. due to authentication or network problems.
"""
try:
result = subprocess.run(
["gh", *args], capture_output=True, text=True, check=True
)
except FileNotFoundError:
sys.exit(
"error: the GitHub CLI ('gh') was not found; please install it "
"(https://cli.github.com/) and authenticate with 'gh auth login'."
)
except subprocess.CalledProcessError as e:
sys.exit(f"error: 'gh {' '.join(args)}' failed:\n{e.stderr.strip()}")
return result.stdout


def gh_generate_notes(tag: str, previous: str) -> str:
"""Call the GitHub generate-notes API via `gh`."""
stdout = run_gh([
"api", f"repos/{REPO}/releases/generate-notes",
"-f", f"tag_name={tag}",
"-f", f"previous_tag_name={previous}",
])
return json.loads(stdout)["body"]


# A release tag of the form cbmc-X.Y or cbmc-X.Y.Z. Tags carrying extra
# suffixes (e.g. cbmc-4.5-sv-comp-2014, cbmc-4.8-incremental, cbmc-5.12-d8598f8)
# are intentionally excluded so they cannot be confused with the corresponding
# bare version when ordering.
_RELEASE_TAG = re.compile(r"^cbmc-\d+\.\d+(?:\.\d+)?$")


def version_key(tag: str):
"""Sort key: the numeric (major, minor, patch) components of a release tag.

\\pre `tag` matches `_RELEASE_TAG`.
"""
return [int(p) for p in tag.split("-", 1)[1].split(".")]


def select_previous_tag(all_tags, tag: str):
"""Return the release tag immediately preceding `tag`.

Only tags matching `_RELEASE_TAG` are considered. If `tag` itself is not
present (it may not have been created yet), the latest existing release
tag is returned. Returns None if there is no release tag at all.
"""
tags = sorted(
(t for t in all_tags if _RELEASE_TAG.match(t)),
key=version_key,
reverse=True,
)
for i, t in enumerate(tags):
if t == tag and i + 1 < len(tags):
return tags[i + 1]
return tags[0] if tags else None


def previous_tag(tag: str) -> str:
"""Find the release tag immediately before `tag` using `gh`."""
stdout = run_gh([
"api", f"repos/{REPO}/tags", "--paginate", "-q", ".[].name",
])
result = select_previous_tag(stdout.splitlines(), tag)
if result is None:
sys.exit(f"Cannot find a tag before {tag}")
return result


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"\bbump\b|dependabot|"
r"\bCI\b|ci:|ci job|GitHub Action|runner|"
r"Compile Java regression|"
r"CODEOWNERS|"
r"clang-format|"
r"Release CBMC"
)

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


@dataclass
class DraftSummary:
"""A drafted summary, split into its HTML comment line and prose body."""

comment: str # leading HTML comment (a DRAFT or TODO marker)
body: str # prose sentence(s); empty when only a TODO is emitted


def _join_highlights(highlights):
if len(highlights) == 1:
return highlights[0]
if len(highlights) == 2:
return f"{highlights[0]} and {highlights[1]}"
return f"{highlights[0]}, {highlights[1]}, and {highlights[2]}"


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

Strategy: highlight the top user-visible feature PRs and count the
remaining (non-feature) changes. This is a *draft* — the release manager
must review and edit it. When no user-visible feature can be identified,
a TODO placeholder is emitted rather than guessing.
"""
# 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, splitting into features and other changes
# (the latter covers bug fixes, refactors, documentation, etc.).
visible = [l for l in pr_lines if not _SKIP.search(l)]
features = [l for l in visible if _FEATURE.search(l)]
others = [l for l in visible if l not in features]

if not features:
return DraftSummary(
comment=f"<!-- TODO: write a summary for CBMC {version} -->",
body="",
)

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[:3]:
title, pr = extract(line)
ref = f" (via #{pr})" if pr else ""
highlights.append(f"{title}{ref}")

prose = f"This release includes {_join_highlights(highlights)}."
n_others = len(others)
if n_others:
prose += (
f" The release also includes {n_others} other change"
f"{'s' if n_others != 1 else ''}."
)

return DraftSummary(
comment="<!-- DRAFT — please review and edit this summary -->",
body=prose,
)


def format_release_notes(notes: str, version: str) -> str:
"""Combine a header, the draft summary, and the GitHub-generated body."""
summary = draft_summary(notes, version)
# Keep the HTML comment on its own line; wrap only the prose body.
body = textwrap.fill(summary.body, width=80) if summary.body else ""
summary_block = f"{summary.comment}\n{body}" if body else summary.comment
return f"# CBMC {version}\n\n{summary_block}\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(
"-o", "--output",
help="Write to file instead of stdout",
)
args = p.parse_args()

prev = args.previous or previous_tag(args.tag)
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)
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
Loading