From 54b1b867b16e6f7da44dc86f5818f2c45a9a2228 Mon Sep 17 00:00:00 2001 From: poshinchen Date: Thu, 9 Jul 2026 12:20:53 -0400 Subject: [PATCH] ci: add release script for workflow --- scripts/release/CLAUDE.md | 65 +++++ scripts/release/notes/action.yml | 51 ++++ scripts/release/notes/group-release-notes.py | 95 +++++++ scripts/release/release_dispatch.py | 279 +++++++++++++++++++ 4 files changed, 490 insertions(+) create mode 100644 scripts/release/CLAUDE.md create mode 100644 scripts/release/notes/action.yml create mode 100644 scripts/release/notes/group-release-notes.py create mode 100755 scripts/release/release_dispatch.py diff --git a/scripts/release/CLAUDE.md b/scripts/release/CLAUDE.md new file mode 100644 index 0000000..1a49a9f --- /dev/null +++ b/scripts/release/CLAUDE.md @@ -0,0 +1,65 @@ +# Release dispatcher + +Triggers the manual `workflow_dispatch` release workflows across the strands +repos from one place, reading state remotely (no clones). When the user says +"release" (or asks what's ready to release), drive the flow below. + +`release_dispatch.py` owns all the mechanics — published versions (PyPI/npm), +commits-since (GitHub API), dispatch, run URL. Do the conversation around it; +never re-derive versions, tags, or `gh` commands yourself. + +Targets: **harness-python**, **harness-typescript**, **shell**, **tools**, +**evals**. harness-python and harness-typescript both release from the +`sdk-python` monorepo via different workflow files. + +## Before starting + +Check `gh auth status` includes the `workflow` scope. If not, tell the user to +run `gh auth refresh -s workflow` and stop. + +## Flow + +1. **Status.** Run `./release_dispatch.py status`. Each line shows the target, + its latest published version, and — on the right — whether there are new + commits since the last release (with subjects), nothing new, or undetermined. + Relay this and note which targets have nothing to release. + +2. **Pick targets.** Ask which to release — any subset. Don't assume all; don't + refuse a "nothing new" / undetermined target if the user insists (the + workflow's own `scan-commits` is the real guard). + +3. **Version per target.** Show the latest version from the status output and + ask the user to **type** the new version. Never auto-bump or suggest one. + +4. **Dispatch.** Confirm the exact command, then run + `./release_dispatch.py dispatch `. This is a **real + release**: the workflow runs the full test/build/inspect suite, then pauses + for a reviewer to approve the `release-gate` environment before it tags and + publishes — so the dispatch alone ships nothing. Give the user the printed + run URL and tell them to approve there when ready. The script validates the + version (bare semver, greater than published) and prints any error — surface + it, don't work around it. + +Repeat 3–4 per selected target, **one at a time**, confirming each. + +## Options to offer when relevant + +- Preview only (build + inspect, no approval/publish): add `--preview`. +- Release a specific commit (ancestor of main): `--sha `. +- Fork testing instead of upstream `strands-agents`: `--owner `. + +## Guardrails + +- Confirm before every dispatch; one target at a time. +- The user types every version — never guess. +- If a run fails at `scan-commits`, it's usually: version not greater than the + last release, no commits since, or a `--sha` not on main. Report the run's + error rather than re-dispatching blindly. + +--- + +Also here: **`notes/`** is the `release-notes` composite action (grouped-by-type +release notes), consumed by release workflows as +`strands-agents/devtools/scripts/release/notes@main`. Not to be confused with +`../strands_release_helper.py`, an older tool that clones + tests + auto-bumps +for the previous template-based release process. diff --git a/scripts/release/notes/action.yml b/scripts/release/notes/action.yml new file mode 100644 index 0000000..fac7a3e --- /dev/null +++ b/scripts/release/notes/action.yml @@ -0,0 +1,51 @@ +name: 'Release Notes' +description: >- + Draft release notes for a tag range, grouped by conventional-commit type + (feat, fix, docs, ...). Runs `git log` over `..` and + buckets the commits into Markdown sections. Shared by the strands release + workflows so every package renders notes the same way. Requires a checkout + with full history and tags (fetch-depth: 0, fetch-tags: true). + +inputs: + prev_tag: + description: 'Previous release tag; the low end of the commit range.' + required: true + new_tag: + description: 'Proposed new tag; used as the notes heading.' + required: true + new_ref: + description: 'High end of the commit range (a SHA or ref). Defaults to new_tag.' + required: false + default: '' + output_file: + description: 'Where to write the rendered Markdown, relative to the workspace.' + required: false + default: 'release-notes.md' + +outputs: + notes_file: + description: 'Path to the rendered release-notes Markdown file.' + value: ${{ steps.render.outputs.notes_file }} + +runs: + using: 'composite' + steps: + - name: Render notes + id: render + shell: bash + env: + PREV_TAG: ${{ inputs.prev_tag }} + NEW_TAG: ${{ inputs.new_tag }} + # Fall back to the tag when no explicit ref is given. + NEW_REF: ${{ inputs.new_ref || inputs.new_tag }} + OUTPUT_FILE: ${{ inputs.output_file }} + run: | + set -euo pipefail + # Feed commits to the grouper as `hashsubject` lines and let it + # bucket them by conventional-commit type (feat, fix, ...). The script + # is stdlib-only, so no dependency install is needed. github.action_path + # points at this action's checkout, wherever the runner placed it. + git log --no-merges --pretty=format:'%h%x09%s' "$PREV_TAG..$NEW_REF" \ + | NEW_TAG="$NEW_TAG" PREV_TAG="$PREV_TAG" \ + python3 "${{ github.action_path }}/group-release-notes.py" > "$OUTPUT_FILE" + echo "notes_file=$OUTPUT_FILE" >> "$GITHUB_OUTPUT" diff --git a/scripts/release/notes/group-release-notes.py b/scripts/release/notes/group-release-notes.py new file mode 100644 index 0000000..56d1511 --- /dev/null +++ b/scripts/release/notes/group-release-notes.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Group release-note commits by conventional-commit type. + +Reads `` lines on stdin (one commit each, as +produced by `git log --pretty=format:'%h%x09%s'`) and writes Markdown +release notes to stdout, bucketed by conventional-commit type (`feat`, +`fix`, `docs`, ...) instead of by author. + +The header is drawn from the `NEW_TAG` / `PREV_TAG` environment variables +so the output matches the previous shortlog-based notes. +""" + +import os +import re +import sys + +# Conventional-commit types in the order they should appear, mapped to +# their section headings. Types not listed here fall through to "Other". +SECTIONS = [ + ("feat", "🚀 Features"), + ("fix", "🐛 Fixes"), + ("perf", "⚡ Performance"), + ("refactor", "♻️ Refactoring"), + ("docs", "📚 Documentation"), + ("test", "✅ Tests"), + ("build", "📦 Build"), + ("ci", "👷 CI"), + ("chore", "🔧 Chores"), + ("revert", "⏪ Reverts"), +] +SECTION_TITLES = dict(SECTIONS) +OTHER_KEY = "other" + +# `type(optional scope)!: subject` — captures the type and the remaining +# subject. The optional `!` and scope are conventional-commit syntax. +COMMIT_RE = re.compile(r"^(?P[a-z]+)(?:\([^)]*\))?!?:\s*(?P.*)$") + + +def classify(subject): + """Return the `section_key` for a commit subject line. + + Only the type is parsed off, for bucketing; the subject itself is kept + verbatim (prefix included) by the caller so the rendered notes still + show the `feat:` / `fix:` convention. + """ + match = COMMIT_RE.match(subject) + if match and match.group("type") in SECTION_TITLES: + return match.group("type") + return OTHER_KEY + + +def main(): + buckets = {} + for line in sys.stdin: + line = line.rstrip("\n") + if not line: + continue + short_hash, _, subject = line.partition("\t") + key = classify(subject) + buckets.setdefault(key, []).append((short_hash, subject.strip())) + + new_tag = os.environ.get("NEW_TAG", "") + prev_tag = os.environ.get("PREV_TAG", "") + + out = [] + out.append(f"## {new_tag}") + out.append("") + out.append( + f"_Auto-drafted from commits in `{prev_tag}..{new_tag}`, grouped by " + "conventional-commit type. Edit on the release page after publish if " + "you want a polished writeup; the canonical release notes live on the " + "website._" + ) + out.append("") + + # Known types first, in declared order; then the "Other" catch-all. + for key, title in SECTIONS + [(OTHER_KEY, "🔖 Other")]: + commits = buckets.get(key) + if not commits: + continue + out.append(f"### {title}") + out.append("") + for short_hash, subject in commits: + out.append(f"- {subject} ({short_hash})") + out.append("") + + if not any(buckets.values()): + out.append("_No commits in range._") + out.append("") + + sys.stdout.write("\n".join(out).rstrip() + "\n") + + +if __name__ == "__main__": + main() diff --git a/scripts/release/release_dispatch.py b/scripts/release/release_dispatch.py new file mode 100755 index 0000000..9b1b60a --- /dev/null +++ b/scripts/release/release_dispatch.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +"""Release dispatcher for the strands repos. + +Triggers the manual `workflow_dispatch` release workflows across repos from one +place, so a releaser doesn't have to open each repo's Actions → "Run workflow" +UI. This is the gh-dispatch model: it reads state remotely (PyPI/npm for the +published version, the GitHub compare API for commits since) and dispatches the +release workflow. It does NOT clone repos, run tests, or compute a version — +the releaser types the version, and the dispatched workflow runs the tests and +gates on the in-run `release-gate` approval before publishing. + +(Distinct from `strands_release_helper.py`, which clones + tests + auto-bumps +for the older template-based release flow.) + +Deterministic plumbing only — the interactive repo selection and version +prompts live in the CLAUDE.md driver that calls this. Two subcommands: + + release_dispatch.py status [target ...] [--json] + For each target: latest published version, whether there are new commits + on main since that release, and the commit subjects. `--json` emits a + machine-readable array for the driver to render. + + release_dispatch.py dispatch [--preview] [--sha SHA] + Validate the version (bare semver, greater than published), dispatch the + target's release workflow, and print the run URL. Real release by default + (dry_run=false → runs the suite, then waits for release-gate approval); + --preview sets dry_run=true (build + inspect only, no approval/publish). + +OWNER defaults to the upstream org `strands-agents`; override with --owner or +the OWNER env var for fork testing. Requires `gh` authenticated with the +`workflow` scope. +""" + +import argparse +import json +import os +import re +import subprocess +import sys +import urllib.error +import urllib.request + +# ── Target registry ────────────────────────────────────────────────────── +# Identify a target by its logical name. `repo` is the name under $OWNER; +# `workflow` is the dispatch file (sdk-python has two, so never key on repo). +# `version` sources the latest published version: "pypi:" or "npm:" +# — the registry is authoritative and avoids malformed-tag surprises. The tag +# for the commits-since compare is derived as `prefix + version`. +SEMVER_RE = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$") + +TARGETS = { + "harness-python": { + "repo": "sdk-python", + "workflow": "release-python.yml", + "prefix": "python/v", + "version": "pypi:strands-agents", + "has_integ": True, + }, + "harness-typescript": { + "repo": "sdk-python", + "workflow": "release-typescript.yml", + "prefix": "typescript/v", + "version": "npm:@strands-agents/sdk", + "has_integ": True, + }, + "shell": { + "repo": "shell", + "workflow": "release.yml", + "prefix": "v", + "version": "pypi:strands-shell", + "has_integ": False, + }, + "tools": { + "repo": "tools", + "workflow": "release.yml", + "prefix": "v", + "version": "pypi:strands-agents-tools", + "has_integ": True, + }, + "evals": { + "repo": "evals", + "workflow": "release.yml", + "prefix": "v", + "version": "pypi:strands-agents-evals", + "has_integ": True, + }, +} + + +def owner() -> str: + return os.environ.get("OWNER") or "strands-agents" + + +def fetch_json(url: str) -> dict: + """GET a URL and parse JSON. Raises urllib errors on failure.""" + req = urllib.request.Request(url, headers={"Accept": "application/json"}) + with urllib.request.urlopen(req, timeout=15) as resp: + return json.loads(resp.read().decode()) + + +def latest_published_version(source: str) -> str: + """Resolve the latest published version from PyPI or npm. + + `source` is "pypi:" or "npm:". Returns the version string, or + "" if the package/registry lookup fails (e.g. never published yet). + """ + kind, _, name = source.partition(":") + try: + if kind == "pypi": + return fetch_json(f"https://pypi.org/pypi/{name}/json")["info"]["version"] + if kind == "npm": + # Scoped names (@scope/pkg) must percent-encode the slash. + enc = name.replace("/", "%2F") + return fetch_json(f"https://registry.npmjs.org/{enc}/latest")["version"] + except (urllib.error.URLError, KeyError, ValueError): + return "" + return "" + + +def gh(args: list[str]) -> tuple[int, str, str]: + """Run a gh command, returning (returncode, stdout, stderr).""" + proc = subprocess.run( + ["gh", *args], capture_output=True, text=True, timeout=60 + ) + return proc.returncode, proc.stdout, proc.stderr + + +def commits_since(repo: str, tag: str) -> dict: + """Compare `tag`..main via the GitHub API. + + Returns {ahead: int, subjects: [str]} on success, or {error: str} if the + tag is missing (never released at that version) or the compare fails. + """ + code, out, err = gh( + ["api", f"repos/{owner()}/{repo}/compare/{tag}...main", + "--jq", "{ahead: .ahead_by, subjects: [.commits[].commit.message | split(\"\\n\")[0]]}"] + ) + if code != 0: + # 404 → tag not found on this owner (common on forks / first release). + return {"error": (err.strip() or "compare failed").splitlines()[-1]} + try: + data = json.loads(out) + except ValueError: + return {"error": "could not parse compare output"} + # Newest-first from the API; reverse to chronological for display. + data["subjects"] = list(reversed(data.get("subjects") or [])) + return data + + +def status(target_names: list[str]) -> list[dict]: + rows = [] + for name in target_names: + t = TARGETS[name] + version = latest_published_version(t["version"]) + row = { + "target": name, + "repo": f"{owner()}/{t['repo']}", + "workflow": t["workflow"], + "version": version, + "prefix": t["prefix"], + } + if version: + tag = f"{t['prefix']}{version}" + row["tag"] = tag + cmp = commits_since(t["repo"], tag) + if "error" in cmp: + row["has_changes"] = None + row["note"] = cmp["error"] + else: + row["ahead"] = cmp["ahead"] + row["has_changes"] = cmp["ahead"] > 0 + row["subjects"] = cmp["subjects"] + else: + row["has_changes"] = None + row["note"] = "no published version found" + rows.append(row) + return rows + + +def print_status_human(rows: list[dict]) -> None: + for r in rows: + if r["has_changes"] is None: + tail = f" ({r.get('note', 'unknown')})" + elif r["has_changes"]: + tail = f" {r['ahead']} new commit(s) since {r['tag']}" + else: + tail = f" no new commits since {r['tag']}" + print(f"{r['target']:<20} latest: {r['version'] or 'n/a':<10}{tail}") + for s in r.get("subjects", [])[:20]: + print(f" - {s}") + + +def dispatch(name: str, version: str, preview: bool, sha: str) -> int: + t = TARGETS[name] + # Validate format up front; the workflow re-checks, but fail fast. + if not SEMVER_RE.match(version): + print(f"error: '{version}' is not bare semver (MAJOR.MINOR.PATCH, no 'v').", file=sys.stderr) + return 2 + published = latest_published_version(t["version"]) + if published and version == published: + print(f"error: {version} is already the published version.", file=sys.stderr) + return 2 + if published: + higher = sorted([published, version], key=lambda v: [int(x) for x in v.split(".")])[-1] + if higher != version: + print(f"error: {version} is not greater than published {published}.", file=sys.stderr) + return 2 + + args = [ + "workflow", "run", t["workflow"], + "-R", f"{owner()}/{t['repo']}", "--ref", "main", + "-f", f"version={version}", + "-f", f"dry_run={'true' if preview else 'false'}", + ] + if sha: + args += ["-f", f"sha={sha}"] + if t["has_integ"]: + args += ["-f", "run_integ_tests=false"] + + mode = "PREVIEW (dry run)" if preview else "REAL RELEASE (waits for release-gate approval)" + print(f"Dispatching {name} v{version} → {owner()}/{t['repo']} / {t['workflow']} [{mode}]") + code, out, err = gh(args) + if code != 0: + print(err.strip() or "dispatch failed", file=sys.stderr) + return 1 + + # gh workflow run prints nothing useful; fetch the run it just created. + code, out, _ = gh([ + "run", "list", "-R", f"{owner()}/{t['repo']}", + "--workflow", t["workflow"], "-L", "1", + "--json", "url,status", "--jq", ".[0].url", + ]) + print(f"Run: {out.strip()}" if code == 0 and out.strip() else "Dispatched (run URL not yet available).") + return 0 + + +def main() -> int: + p = argparse.ArgumentParser(description="Dispatch strands release workflows.") + p.add_argument("--owner", help="GitHub org/user owning the repos (default: $OWNER or strands-agents).") + # Not required: a bare invocation defaults to `status` (all targets) — + # the natural entry point for the release flow. + sub = p.add_subparsers(dest="cmd") + + s = sub.add_parser("status", help="Show published version + commits since, per target.") + s.add_argument("targets", nargs="*", help="Targets to show (default: all).") + s.add_argument("--json", action="store_true", help="Emit machine-readable JSON.") + + d = sub.add_parser("dispatch", help="Dispatch a target's release workflow.") + d.add_argument("target", choices=list(TARGETS)) + d.add_argument("version", help="Bare semver to release, e.g. 0.3.0.") + d.add_argument("--preview", action="store_true", help="Dry run: build + inspect only, no approval/publish.") + d.add_argument("--sha", default="", help="Optional commit SHA (must be an ancestor of main).") + + args = p.parse_args() + if args.owner: + os.environ["OWNER"] = args.owner + + if args.cmd in (None, "status"): + # Bare invocation → status for all targets. + names = getattr(args, "targets", None) or list(TARGETS) + unknown = [n for n in names if n not in TARGETS] + if unknown: + print(f"error: unknown target(s): {', '.join(unknown)}", file=sys.stderr) + return 2 + rows = status(names) + if getattr(args, "json", False): + print(json.dumps(rows, indent=2)) + else: + print_status_human(rows) + return 0 + + if args.cmd == "dispatch": + return dispatch(args.target, args.version, args.preview, args.sha) + + return 0 + + +if __name__ == "__main__": + sys.exit(main())