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
65 changes: 65 additions & 0 deletions scripts/release/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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 <target> <version>`. 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 <commit>`.
- Fork testing instead of upstream `strands-agents`: `--owner <user>`.

## 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.
51 changes: 51 additions & 0 deletions scripts/release/notes/action.yml
Original file line number Diff line number Diff line change
@@ -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 `<prev_tag>..<new_ref>` 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 `hash<TAB>subject` 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"
95 changes: 95 additions & 0 deletions scripts/release/notes/group-release-notes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Group release-note commits by conventional-commit type.

Reads `<short-hash><TAB><subject>` 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<type>[a-z]+)(?:\([^)]*\))?!?:\s*(?P<subject>.*)$")


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()
Loading