From e931193f50e145b331248cdbcca84b1fc4f79800 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gustavo=20Andr=C3=A9=20dos=20Santos=20Lopes?= Date: Fri, 3 Jul 2026 10:46:21 +0100 Subject: [PATCH] skill to prepare releases --- .agents/skills/release-prep/SKILL.md | 115 ++++++++ .../skills/release-prep/agents/openai.yaml | 4 + .../references/release-findings.md | 29 ++ .../release-prep/scripts/prepare_release.py | 269 ++++++++++++++++++ .claude/skills/release-prep | 1 + 5 files changed, 418 insertions(+) create mode 100644 .agents/skills/release-prep/SKILL.md create mode 100644 .agents/skills/release-prep/agents/openai.yaml create mode 100644 .agents/skills/release-prep/references/release-findings.md create mode 100755 .agents/skills/release-prep/scripts/prepare_release.py create mode 120000 .claude/skills/release-prep diff --git a/.agents/skills/release-prep/SKILL.md b/.agents/skills/release-prep/SKILL.md new file mode 100644 index 000000000..93951341c --- /dev/null +++ b/.agents/skills/release-prep/SKILL.md @@ -0,0 +1,115 @@ +--- +name: release-prep +description: Prepare releases from this repository checkout. Use when the agent needs to inspect release readiness, compare commits since the previous release tag, summarize merged GitHub PRs, validate GitHub release/tag state, create or update a local release version branch, bump the repository version file, draft/update release changelog files, or produce safe next-step commands for opening the release PR and tagging after merge. +--- + +# Release Prep + +## Workflow + +Run the bundled script first from the repository checkout: + +```bash +uv run .agents/skills/release-prep/scripts/prepare_release.py inspect +``` + +Use the output to confirm: + +- `origin/master` has been fetched and is the intended base for normal releases. +- The worktree has no uncommitted changes before local preparation. +- The latest reachable tag and GitHub release match. +- Commits and merged PRs since the latest tag are understood. +- No existing tag, release, or remote release branch conflicts with the target version. +- Whether `include/ddwaf.h` changed since the previous tag — if so, the upgrading guide must be updated (see below). + +For historical evidence and repository-specific conventions, read `references/release-findings.md`. + +## Local Preparation + +Prepare a release locally only after the target version is known: + +```bash +uv run .agents/skills/release-prep/scripts/prepare_release.py prepare 2.0.1 +``` + +The script creates or switches to `release/` from `origin/master`, updates the top-level `version` file, and prints the PR/commit context again for reference. It deliberately does not write the changelog — write that yourself (see below) so entries are real summaries, not placeholders. + +IMPORTANT: Do not push branches, push tags, publish GitHub releases, or mark draft releases as published unless the user explicitly confirms. + +## Writing the changelog + +The GitHub Actions release job (`.github/workflows/build.yml`) uses `docs/changelog/CHANGELOG-latest.md` verbatim as the draft release body (`body_path`). That means the symlink must always point to a file containing *only the new release's* notes — never a cumulative history. Historical entries for old releases within the same major live in a separate aggregate file (e.g. `CHANGELOG-v1.x.md` holds every `1.x.y` release; `CHANGELOG-v2.x.md` should hold every 2.x release and so on). + +For every release, do all of the following: + +1. Create a new single-release file `docs/changelog/CHANGELOG-v.md` containing only this release's notes: + + ```markdown + # v + + + + ## Release Changelog + + ### Changes + + - ([#]()) + + ### Fixes + + - ([#]()) + + ### Miscellaneous + + - ([#]()) + ``` + + Sort each PR into Changes, Fixes, or Miscellaneous by reading its title/description — don't dump everything into Changes. + +2. Fold the *previous* latest file's content into the the previous version major's aggregate file, `docs/changelog/CHANGELOG-v.x.md`: + - If the aggregate doesn't exist yet, create it with `# libddwaf release` as the title, then the previous file's content demoted by one heading level and prefixed with `## v` (`# v` → `## v`, `## Release Changelog` → `### Release changelog`, `### Changes`/`### Fixes`/`### Miscellaneous` → `#### Changes`/`#### Fixes`/`#### Miscellaneous`). See `docs/changelog/CHANGELOG-v1.x.md` for the target shape. + - If it already exists, prepend the same demoted `## v` section to the top of it. + - Delete the now-folded standalone previous-version file — its content only lives in the aggregate from here on. + +3. Repoint the symlink at the new release-only file: + + ```bash + ln -sf CHANGELOG-v.md docs/changelog/CHANGELOG-latest.md + ``` + +## Writing the upgrading guide + +If any merged PR in range changes the public C API or ABI (anything in `include/ddwaf.h`, or behavior it documents), describe the change in the upgrading guide, following the same latest-file/historical-aggregate split as the changelog. `docs/upgrading/` isn't read by the release automation, but `docs/upgrading/UPGRADING-latest.md` is still meant to describe only the upgrade path *into* the current release, not the full history — historical entries live in the major's aggregate file (similar to the changelog). + +If this release changes the API/ABI, apply the same logic as for the changelog files, mutatis mutandis. + +## Manual Checks + +Expected standard release shape: + +- Branch: `release/` without a leading `v`. +- Base branch: `master`. +- PR title: `Release v`. +- Release commit: update `version`, `docs/changelog`, and — only when the API/ABI changed — `docs/upgrading`. + +Commands to open the release PR, once the changelog (and upgrading guide, if needed) are written: + +```bash +git add version docs/changelog/ docs/upgrading/ +git commit -m 'Release v' -S +git push origin release/:release/ +gh pr create --repo DataDog/libddwaf --base master --head release/ --title 'Release v' +``` + +Use the explicit `local:remote` refspec for the push, not a bare branch name — the release branch is created from `origin/master`, so it tracks `master` as its upstream, and with `push.default=upstream` a bare `git push origin release/` silently pushes to `master` instead of creating the release branch. + +After the release PR merges and the user explicitly approves publishing steps, tag the merge commit with the raw version string, not `v`: + +```bash +git switch master +git pull --ff-only +git tag +git push origin +``` + +GitHub Actions creates a draft release named `v` from any pushed tag and uses `docs/changelog/CHANGELOG-latest.md` as the release body. diff --git a/.agents/skills/release-prep/agents/openai.yaml b/.agents/skills/release-prep/agents/openai.yaml new file mode 100644 index 000000000..b0af1fd3a --- /dev/null +++ b/.agents/skills/release-prep/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Release Prep" + short_description: "Prepare release branches and checks" + default_prompt: "Use $release-prep to prepare a release in this repository." diff --git a/.agents/skills/release-prep/references/release-findings.md b/.agents/skills/release-prep/references/release-findings.md new file mode 100644 index 000000000..cfe748473 --- /dev/null +++ b/.agents/skills/release-prep/references/release-findings.md @@ -0,0 +1,29 @@ +# libddwaf Release Findings + +Use this as repository-specific context for release preparation. + +## Current release workflow + +- GitHub Actions workflows `Build`, `Test`, and `Fuzz` run on pull requests, pushes to `master`, and all pushed tags. +- `.github/workflows/build.yml` has a `release` job gated by `startsWith(github.ref, 'refs/tags/')`. +- The release job downloads build artifacts, copies JSON schemas from `schema/*.json`, and uses `softprops/action-gh-release`. +- The action creates a draft GitHub release, names it `v${{ github.ref_name }}`, and uses `docs/changelog/CHANGELOG-latest.md` as the body. +- Tags are raw semver strings such as `2.0.0`, not `v2.0.0`. +- Package names come from the CMake project version, with exact tags preferred by `git describe --exact-match --tags HEAD`. +- NuGet packaging reads the top-level `version` file. + +## Version and changelog files + +- The top-level `version` file is the release version source of truth. +- `CMakeLists.txt` reads `version`, strips alpha/beta suffixes for the project declaration, then restores `PROJECT_VERSION` and `CMAKE_PROJECT_VERSION`. +- `src/version.hpp` is generated from `src/version.hpp.in`; do not edit it for release prep. +- Current v2 release notes live under `docs/changelog/`. +- `docs/changelog/CHANGELOG-latest.md` is a symlink to the release body file used by the GitHub release action. + +## Branch and PR naming + +- Standard release branches use `release/` without a leading `v`. +- Standard release PRs target `master` and use titles like `Release v2.0.0`. +- Recent examples: + - PR #496: `release/2.0.0` into `master`, title `Release v2.0.0`, one release commit, changed `version`, `docs/changelog/CHANGELOG-v2.0.0.md`, and `docs/upgrading/UPGRADING-v2.0.md`. + - PR #463: `release/1.29.0` into `master`, title `Release v1.29.0`, changed `version` and the old `CHANGELOG.md`. diff --git a/.agents/skills/release-prep/scripts/prepare_release.py b/.agents/skills/release-prep/scripts/prepare_release.py new file mode 100755 index 000000000..145919d06 --- /dev/null +++ b/.agents/skills/release-prep/scripts/prepare_release.py @@ -0,0 +1,269 @@ +#!/usr/bin/env -S uv run +# /// script +# requires-python = ">=3.11" +# /// + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +from pathlib import Path +from typing import Any + + +SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+(?:-(?:alpha|beta)\d*)?$") +PR_RE = re.compile(r"\(#(\d+)\)") +REPO = "DataDog/libddwaf" +REQUIRED_FILES = [".git", "version", "CMakeLists.txt", ".github/workflows/build.yml"] + + +def main() -> None: + args = build_parser().parse_args() + try: + args.func(args.repo.resolve(), args) + except FileNotFoundError as exc: + raise SystemExit(f"missing required executable: {exc.filename}") from exc + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Inspect or prepare a libddwaf release.") + parser.add_argument("--repo", type=Path, default=Path.cwd(), help="libddwaf checkout path") + parser.add_argument("--base", default="origin/master", help="release base ref") + parser.add_argument("--previous-tag", help="override previous release tag") + parser.add_argument("--no-fetch", action="store_true", help="skip git fetch --tags --prune origin") + subcommands = parser.add_subparsers(dest="command", required=True) + + inspect_cmd = subcommands.add_parser("inspect", help="read-only release inspection") + inspect_cmd.add_argument("--version", help="target version to check for conflicts") + inspect_cmd.set_defaults(func=inspect_release) + + prepare_cmd = subcommands.add_parser("prepare", help="make local release branch and file changes") + prepare_cmd.add_argument("version", help="target version, for example 2.0.1") + prepare_cmd.add_argument("--branch", help="override branch name; defaults to release/") + prepare_cmd.set_defaults(func=prepare_release) + return parser + + +def inspect_release(repo: Path, args: argparse.Namespace) -> None: + require_repo(repo) + fetch(repo, args.no_fetch) + previous, commits, prs = release_context(repo, args) + print(render_inspect(repo, args.base, previous, commits, prs, args.version)) + + +def prepare_release(repo: Path, args: argparse.Namespace) -> None: + if not SEMVER_RE.match(args.version): + raise SystemExit("version must look like 2.0.1, 2.1.0-alpha0, or 2.1.0-beta1") + + require_repo(repo) + fetch(repo, args.no_fetch) + ensure_clean(repo) + previous, commits, prs = release_context(repo, args) + + if git(repo, "rev-parse", "--verify", f"refs/tags/{args.version}", check=False).returncode == 0: + raise SystemExit(f"tag already exists locally: {args.version}") + if release_info(repo, args.version): + raise SystemExit(f"GitHub release already exists for tag: {args.version}") + + branch = args.branch or f"release/{args.version}" + create_or_switch_branch(repo, branch, args.base) + (repo / "version").write_text(args.version, encoding="utf-8") + + print(render_inspect(repo, args.base, previous, commits, prs, args.version)) + print(f"\n## Branch: {branch}") + print("`version` bumped. Write the changelog and next commands per SKILL.md.") + + +def release_context(repo: Path, args: argparse.Namespace) -> tuple[str, list[dict[str, str]], list[dict[str, Any]]]: + previous = latest_tag(repo, args.base, args.previous_tag) + commits = commits_since(repo, previous, args.base) + prs = [pr_info(repo, number) for number in pr_numbers(commits)] + return previous, commits, prs + + +def render_inspect( + repo: Path, + base: str, + previous: str, + commits: list[dict[str, str]], + prs: list[dict[str, Any]], + target_version: str | None, +) -> str: + ahead, behind = upstream_counts(repo) + previous_release = release_info(repo, previous) + target_release = release_info(repo, target_version) + branch = git(repo, "branch", "--show-current").stdout or "(detached)" + worktree = "clean" if not git(repo, "status", "--short").stdout else "has local changes" + + lines = [ + "# libddwaf release inspection", + "", + f"- Repo: `{repo}`", + f"- Branch: `{branch}`", + f"- Base: `{base}`", + f"- Current `version`: `{version_file(repo)}`", + f"- Previous tag: `{previous}`", + ] + if ahead is not None and behind is not None: + lines.append(f"- Upstream divergence: ahead `{ahead}`, behind `{behind}`") + lines.append(f"- Worktree: {worktree}") + + if previous_release: + lines.append( + f"- GitHub release for `{previous}`: `{previous_release['name']}` " + f"draft={previous_release['isDraft']} prerelease={previous_release['isPrerelease']} " + f"published={previous_release['publishedAt']}" + ) + else: + lines.append(f"- GitHub release for `{previous}`: not found") + + if target_version: + lines.append(f"- Target branch: `release/{target_version}`") + lines.append(f"- Existing target GitHub release: {'yes' if target_release else 'no'}") + + api_changed = "yes (update docs/upgrading)" if api_header_changed(repo, previous, base) else "no" + lines.append(f"- `include/ddwaf.h` changed since `{previous}`: {api_changed}") + + add_section(lines, "## Remote release/base branches", [f"- `{branch}`" for branch in release_branches(repo)]) + add_section(lines, f"## Commits since `{previous}` on `{base}`", [f"- `{c['short']}` {c['subject']}" for c in commits]) + add_section( + lines, + "## Merged PRs in range", + [f"- #{pr['number']} {pr.get('title') or '(title unavailable)'} {pr.get('url') or ''}".rstrip() for pr in prs], + "- none detected from first-parent merge commits", + ) + return "\n".join(lines) + + +def require_repo(repo: Path) -> None: + missing = [path for path in REQUIRED_FILES if not (repo / path).exists()] + if missing: + raise SystemExit(f"not a libddwaf checkout or missing files: {', '.join(missing)}") + + +def fetch(repo: Path, skip: bool) -> None: + if not skip: + git(repo, "fetch", "--tags", "--prune", "origin") + + +def ensure_clean(repo: Path) -> None: + if git(repo, "status", "--short").stdout: + raise SystemExit("refusing to prepare release with uncommitted changes") + + +def create_or_switch_branch(repo: Path, branch: str, base: str) -> None: + if git(repo, "rev-parse", "--verify", branch, check=False).returncode == 0: + git(repo, "switch", branch) + else: + git(repo, "switch", "-c", branch, base) + + +def version_file(repo: Path) -> str: + return (repo / "version").read_text(encoding="utf-8").strip() + + +def latest_tag(repo: Path, base: str, override: str | None) -> str: + return override or git(repo, "describe", "--tags", "--abbrev=0", base).stdout + + +def upstream_counts(repo: Path) -> tuple[int | None, int | None]: + upstream = git(repo, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}", check=False) + if upstream.returncode != 0: + return None, None + counts = git(repo, "rev-list", "--left-right", "--count", f"HEAD...{upstream.stdout}").stdout.split() + return (int(counts[0]), int(counts[1])) if len(counts) == 2 else (None, None) + + +def commits_since(repo: Path, tag: str, base: str) -> list[dict[str, str]]: + output = git(repo, "log", "--first-parent", "--format=%H%x09%h%x09%s", f"{tag}..{base}").stdout + commits = [] + for line in output.splitlines(): + full, short, subject = line.split("\t", 2) + commits.append({"sha": full, "short": short, "subject": subject}) + return commits + + +def pr_numbers(commits: list[dict[str, str]]) -> list[int]: + numbers = [] + for commit in commits: + match = PR_RE.search(commit["subject"]) + if match and int(match.group(1)) not in numbers: + numbers.append(int(match.group(1))) + return numbers + + +def pr_info(repo: Path, number: int) -> dict[str, Any]: + data = gh_json( + repo, + "pr", + "view", + str(number), + "--repo", + REPO, + "--json", + "number,title,url,mergedAt,headRefName,baseRefName,author", + ) + if data is None: + return {"number": number, "title": None, "url": None} + data["author"] = (data.get("author") or {}).get("login") + return data + + +def release_info(repo: Path, tag: str | None) -> dict[str, Any] | None: + if not tag: + return None + return gh_json( + repo, + "release", + "view", + tag, + "--repo", + REPO, + "--json", + "tagName,name,isDraft,isPrerelease,publishedAt,url,targetCommitish", + ) + + +def api_header_changed(repo: Path, previous: str, base: str) -> bool: + return bool(git(repo, "diff", "--name-only", f"{previous}..{base}", "--", "include/ddwaf.h").stdout) + + +def release_branches(repo: Path) -> list[str]: + output = git(repo, "branch", "-r", "--list", "origin/release/*", "origin/libddwaf-*").stdout + return [line.strip() for line in output.splitlines() if line.strip()] + + +def add_section(lines: list[str], title: str, items: list[str], empty: str = "- none") -> None: + lines.extend(["", title]) + lines.extend(items or [empty]) + + +def gh_json(repo: Path, *args: str) -> dict[str, Any] | None: + result = run(repo, ["gh", *args], check=False) + return None if result.returncode != 0 else json.loads(result.stdout) + + +def git(repo: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]: + return run(repo, ["git", *args], check=check) + + +def run(repo: Path, args: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]: + result = subprocess.run( + args, + cwd=repo, + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if check and result.returncode != 0: + raise SystemExit(f"command failed: {' '.join(args)}\n{result.stderr.strip()}") + result.stdout = result.stdout.strip() + result.stderr = result.stderr.strip() + return result + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/release-prep b/.claude/skills/release-prep new file mode 120000 index 000000000..696b8a37d --- /dev/null +++ b/.claude/skills/release-prep @@ -0,0 +1 @@ +../../.agents/skills/release-prep \ No newline at end of file