Skip to content
Merged
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
123 changes: 123 additions & 0 deletions .claude/skills/release-package/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
---
name: release-package
description: Release one @opennsw package from ui-packages — after its version bump PR has merged, draft the package's release notes from only the PRs that belong to it (Package/<dir> label or files under packages/<dir>/) since its previous release, and create a DRAFT GitHub Release pinned to the bump commit for review. Use it whenever someone wants to release, publish or cut a version of a package here, asks for release notes or what changed in a package since its last release, or says the bump PR is merged, even if they don't say "release notes". It only ever creates a draft; it never tags, pushes, publishes or dispatches a workflow.
---

# Release a package

Drafts the GitHub Release for one package. Publishing that release (a person clicks **Publish**) creates the tag `<dir>-v<version>`, and the tag triggers `.github/workflows/release.yml`, which builds and **stages** the package on npm. A maintainer then approves the staged package on npm with 2FA, and only then is it installable. So the draft is the last point where a person can still fix the notes, and the npm approval is the last gate before the code goes live.

The repo holds several packages that release independently, so the notes must carry only what changed **in this package**: another package's PRs, repo-wide chores and the bump PR itself stay out.

## Hard rules

- **Create a draft, nothing more.** Never publish the release, push or create a tag, run `gh workflow run`, or edit files in the repo. Publishing starts the release of the package to npm; that is a person's call, made after reading the draft, and the npm approval is a second, separate one.
- **Pin the release to the bump commit** (`--target <bump_sha>`), never to `main`. PRs merged after the bump must not ship under this tag.
- **Back every statement with evidence** — a PR, its "Release notes" section, or its diff. If a breaking change doesn't say what consumers have to change and the diff doesn't make it obvious, list it as an open question instead of guessing. A wrong migration note does more harm than a missing one.
- **Write for the people who install the package**, not for the maintainers. Use absolute links only; the notes are shown on GitHub and copied nowhere else.
- Scratch files go in your scratchpad directory, never in the repo.

## Steps

### 1. Preflight

```bash
gh auth status
git fetch origin --tags
```

If `gh auth status` fails, tell the user and stop. Work from `origin/main`: a release is cut from it, and a feature branch describes the wrong code.

If the user didn't name the package, list `git ls-tree --name-only origin/main packages/` and ask which one.

### 2. Resolve the release

```bash
.claude/skills/release-package/scripts/resolve.sh --package <dir>
```

It prints `name`, `version`, `tag`, `bump_sha`, `prev_tag` and `prev_sha`, and refuses (exit 1) when the version is already released or a release for it exists, even as a draft. In that case tell the user: the version needs bumping first (Actions → **Release - Bump package version**).

On exit 3 it could not find something and says what to pass:

- No `chore(release): bump … to v<version>` commit on `origin/main` → ask the user which commit to release (it must be on `origin/main`), and pass it as `--bump <sha>`.
- No earlier `<dir>-v*` tag → ask which commit the notes should start after, and pass it as `--from <ref>`.

Show the user what you resolved (package, version, tag, previous tag, bump commit) before going on.

### 3. Collect

```bash
.claude/skills/release-package/scripts/collect.sh --package <dir> --from <prev_sha> --to <bump_sha> > <scratchpad>/release-input.md
```

Read all of it. It lists, in the notes' own format (`* <title> by @author in <url>`), the PRs that belong to the package, grouped as Breaking Changes / New Features / Improvements / Bug Fixes / Other Changes, with the **Release notes** section of each PR beneath it, then New Contributors, then the PRs it **left out** and why.

A PR belongs to the package when it changes a file under `packages/<dir>/` **or** carries the `Package/<dir>` label. It is left out when it is `skip-changelog`, a `chore(release)` bump, opened by a bot, or belongs to another package.

Lines starting with `⚠` need your judgement:

- _no Type/\* label; placed by its title_ — the section is a guess from the title. Check it against the diff (`gh pr diff <n>`), and tell the user the PR is unlabelled.
- _changes packages/\<dir\>/ but has no Package/\<dir\> label_ — included by path; fine, but mention it.
- _has Package/\<dir\> but changes nothing under packages/\<dir\>/_ — someone labelled it by hand, probably because it affects the package indirectly. Keep it only if the PR is something consumers would notice.
- _also changes: \<other package\>_ — the PR spans packages; it appears in both packages' notes. Say in the line what changed for _this_ package if the title doesn't.

Check the **Left out** list too: anything there that a consumer of this package would notice is a labelling mistake to raise with the user (for example, a repo-wide change to `pnpm-workspace.yaml` that alters the build).

### 3b. Understand the breaking changes

For every entry under Breaking Changes, find what consumers must change. Take it from the PR's **Release notes** section; if that is empty, read the PR (`gh pr view <n>`) and its diff. Write one clear sentence, for example: "`x-xml.writeTo` now needs a leading slash: rename `a/b` to `/a/b`." Never invent one: if you can't tell, list it as an open question.

### 4. Write the notes

Match the existing releases (`gh release view <prev_tag> --json body --jq .body`): the same headings, in this order, leaving out empty ones. Start from the collector's lines, drop the `⚠` and `Release notes:` sub-bullets, and put the migration sentence of each breaking change under it:

```markdown
## What's Changed

### Breaking Changes

* feat(scope)!: title by @author in https://github.com/OpenNSW/ui-packages/pull/N
- What to change: …

### New Features

* …

### Improvements

### Bug Fixes

### New Contributors

* @login made their first contribution in https://github.com/OpenNSW/ui-packages/pull/N

**Full Changelog**: https://github.com/OpenNSW/ui-packages/compare/<prev_tag>...<tag>
```

- Keep the PR's title as the line (it is a Conventional Commit); don't rewrite it into prose.
- `Other Changes` holds `Type/Task` and untyped PRs that a consumer would still notice. Put a PR a consumer wouldn't notice in neither; raise it as a labelling question instead.
- Write the file to `<scratchpad>/release-notes.md`.

### 5. Show, then create the draft

Show the user the notes, then the **open questions**: unlabelled PRs and the section you chose for them, multi-package PRs, breaking changes with no migration note, and anything in "Left out" that looks wrong. Wait for their go-ahead; they may want to fix labels or wording first.

Then:

```bash
gh release create <tag> --repo OpenNSW/ui-packages --draft --target <bump_sha> --title <tag> --notes-file <scratchpad>/release-notes.md
```

The title is the tag name, as for earlier releases. Add `--prerelease` when the version has a `-suffix`.

### 6. Hand off

Give the user the draft's URL and tell them plainly what happens next:

1. Read the draft on GitHub and edit it if needed.
2. **Publish** it. That creates the tag `<tag>` on `<bump_sha>`, which starts `release.yml`; it builds and **stages** `<name>@<version>` on npm. Nothing is installable yet.
3. A maintainer **approves the staged package** on npm with 2FA (the Staged Packages tab on npmjs.com, or `npm stage list <name>` then `npm stage approve <stage-id>`). The job summary of the `release.yml` run has the commands, and `npm stage download <stage-id>` lets them inspect the tarball first.
4. Check it is live once approved: `gh run list --workflow release.yml --limit 1` and `npm view <name>@<version> version` (run outside the repo, because the root `devEngines` makes npm refuse to run inside it).

If it won't be published today, nothing is lost: the draft stays until it is published or deleted.
141 changes: 141 additions & 0 deletions .claude/skills/release-package/scripts/collect.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
#!/usr/bin/env bash
# Collects what the release-package skill needs to write one package's release
# notes: every PR merged between two refs that belongs to the package, grouped
# the way the notes are, each with its "Release notes" section; then the PRs
# that were left out and why, and the ones that need a person's judgement.
# Read-only: it fetches nothing and edits nothing.
#
# A PR belongs to the package when it changes a file under packages/<pkg>/ OR
# carries the Package/<pkg> label. It is left out when it is labelled
# skip-changelog, is a `chore(release)` bump, or was opened by a bot.
#
# Usage: collect.sh --package <dir> --from <ref> [--to <ref>] --to defaults to origin/main
set -euo pipefail

REPO=OpenNSW/ui-packages
usage() { echo "usage: collect.sh --package <dir> --from <ref> [--to <ref>]" >&2; exit 2; }

pkg="" from="" to="origin/main"
while [ $# -gt 0 ]; do
case "$1" in
--package) pkg="${2:-}"; shift 2 ;;
--from) from="${2:-}"; shift 2 ;;
--to) to="${2:-}"; shift 2 ;;
*) usage ;;
esac
done
[ -n "$pkg" ] && [ -n "$from" ] && [ -n "$to" ] || usage
[[ "$pkg" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || { echo "collect.sh: '$pkg' is not a valid package directory name" >&2; exit 1; }
for ref in "$from" "$to"; do
git rev-parse --verify --quiet "${ref}^{commit}" > /dev/null || { echo "collect.sh: unknown ref '$ref'" >&2; exit 1; }
done
for tool in gh jq; do
command -v "$tool" > /dev/null || { echo "collect.sh: needs $tool" >&2; exit 1; }
done

pkg_label="Package/${pkg}"
pkg_path="packages/${pkg}/"
range="${from}..${to}"
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT

# Squash merges end their subject with "(#NNN)"; the last one is the PR.
# A commit without one reached main without a PR; it is listed (if it touches
# the package) on its own.
git log --first-parent --reverse --format='%H%x09%s' "$range" > "$tmp/commits"
: > "$tmp/prs.jsonl"
: > "$tmp/direct"
n_total=0
while IFS=$'\t' read -r sha subject; do
n=$(printf '%s\n' "$subject" | sed -nE 's/.*\(#([0-9]+)\)[[:space:]]*$/\1/p')
# The files the commit changed against its parent (the whole commit, for the root).
files=$(git diff-tree --root -r --name-only --no-commit-id "$sha" | jq -R . | jq -s -c .)
if [ -z "$n" ]; then
if jq -e --arg pp "$pkg_path" 'any(.[]; startswith($pp))' <<< "$files" > /dev/null; then
printf '%s %s\n' "${sha:0:7}" "$subject" >> "$tmp/direct"
fi
continue
fi
n_total=$((n_total + 1))
gh pr view "$n" --repo "$REPO" --json number,title,url,labels,author,body > "$tmp/pr.json"

# One line of JSON per PR: whether it belongs to the package (out = null) or
# why not, which section it lands in, its Release notes, and what needs a look.
jq -c --arg pkg "$pkg" --arg pl "$pkg_label" --arg pp "$pkg_path" --argjson files "$files" '
def has($n): any(.[]; . == $n);
([.labels[].name]) as $labels
| (.title | capture("^(?<type>[a-z]+)(\\([^)]*\\))?(?<bang>!)?:")? // {type: "", bang: null}) as $t
| ($files | map(select(startswith($pp)))) as $own
| ($files | map(select(test("^packages/[^/]+/")) | sub("^packages/(?<d>[^/]+)/.*$"; .d)) | unique | map(select(. != $pkg))) as $others
| ($labels | has($pl)) as $labelled
| (any($labels[]; . == "breaking change" or startswith("Type/")) | not) as $unlabelled
| (if ($labels | has("skip-changelog")) then "skip-changelog"
elif (.title | test("^chore\\(release\\):")) then "a release bump"
elif (.author.is_bot and ($labels | any(.[]?; startswith("Type/")) | not)) then "opened by a bot"
elif ($labelled or ($own | length) > 0) then null
else "belongs to another package" end) as $out
| (if ($labels | has("breaking change")) then "Breaking Changes"
elif ($labels | has("Type/New Feature")) then "New Features"
elif ($labels | has("Type/Improvement")) then "Improvements"
elif ($labels | has("Type/Bug")) then "Bug Fixes"
elif ($labels | has("Type/Task")) then "Other Changes"
elif $t.bang then "Breaking Changes"
elif $t.type == "feat" then "New Features"
elif $t.type == "fix" then "Bug Fixes"
else "Other Changes" end) as $section
| {number, title, url, author: .author.login, bot: .author.is_bot, out: $out, section: $section,
flags: [
(if $unlabelled then "no Type/* label; placed by its title" else empty end),
(if $labelled and ($own | length) == 0 then "has \($pl) but changes nothing under \($pp)" else empty end),
(if (($labelled | not) and ($own | length) > 0) then "changes \($pp) but has no \($pl) label" else empty end),
(if ($others | length) > 0 then "also changes: \($others | join(", "))" else empty end)
],
notes: (
.body // "" | gsub("\r"; "") | gsub("(?s)<!--.*?-->"; "")
| [scan("(?is)(?:^|\n)##[ \t]*Release notes[ \t]*\n(.*?)(?=\n##[ \t]|$)")] | (.[0][0] // "")
| gsub("^\\s+|\\s+$"; "")
| if (ascii_downcase | gsub("[\\s.]"; "")) as $x | ($x == "" or $x == "n/a" or $x == "na" or $x == "none" or $x == "-") then "" else . end
)}
' "$tmp/pr.json" >> "$tmp/prs.jsonl"
done < "$tmp/commits"

n_in=$(jq -s '[.[] | select(.out == null)] | length' "$tmp/prs.jsonl")

# Who made a first contribution to the repository with a PR in this release?
included=$(jq -s -c '[.[] | select(.out == null) | .number]' "$tmp/prs.jsonl")
gh pr list --repo "$REPO" --state merged --limit 1000 --json number,url,author,mergedAt > "$tmp/merged.json"

echo "# Release input: ${pkg}, ${from} → ${to}"
echo
echo "${n_total} pull requests merged in this range; ${n_in} belong to ${pkg}."

for section in "Breaking Changes" "New Features" "Improvements" "Bug Fixes" "Other Changes"; do
jq -r --arg s "$section" '
select(.out == null and .section == $s)
| "* \(.title) by @\(.author) in \(.url)"
+ (if (.flags | length) > 0 then "\n - ⚠ " + (.flags | join("\n - ⚠ ")) else "" end)
+ (if .notes != "" then "\n - Release notes: " + (.notes | gsub("\n"; "\n ")) else "" end)
' "$tmp/prs.jsonl" > "$tmp/section"
if [ -s "$tmp/section" ]; then
echo; echo "### ${section}"; echo; cat "$tmp/section"
fi
done

jq -r --argjson inc "$included" '
[.[] | select(.author.is_bot | not)] | group_by(.author.login)
| map(sort_by(.mergedAt) | .[0])
| map(select(.number as $n | any($inc[]; . == $n)))
| sort_by(.mergedAt) | .[]
| "* @\(.author.login) made their first contribution in \(.url)"
' "$tmp/merged.json" > "$tmp/newcomers"
if [ -s "$tmp/newcomers" ]; then
echo; echo "### New Contributors"; echo; cat "$tmp/newcomers"
fi

jq -r 'select(.out != null) | "* #\(.number) \(.title) (@\(.author)): \(.out)"' "$tmp/prs.jsonl" > "$tmp/left_out"
if [ -s "$tmp/left_out" ]; then
echo; echo "## Left out"; echo; cat "$tmp/left_out"
fi
if [ -s "$tmp/direct" ]; then
echo; echo "## Commits to ${pkg} without a PR"; echo; sed 's/^/* /' "$tmp/direct"
fi
93 changes: 93 additions & 0 deletions .claude/skills/release-package/scripts/resolve.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# Works out what a release of one package is, and refuses if it can't be released:
# the version on origin/main, the tag it will get, the bump commit the release
# must be pinned to, and the previous release to diff against. Read-only: it
# fetches nothing (run `git fetch origin --tags` first) and edits nothing.
#
# Usage: resolve.sh --package <dir> [--bump <sha>] [--from <ref>]
# --bump use this commit instead of finding the "chore(release): bump" commit
# --from use this ref as the previous release instead of the newest tag
#
# Prints key=value lines (package, name, version, tag, bump_sha, prev_tag,
# prev_sha). Exit 1 when the package can't be released, 3 when the bump commit
# or previous release can't be found and the caller has to ask the user.
set -euo pipefail

REPO=OpenNSW/ui-packages
usage() { echo "usage: resolve.sh --package <dir> [--bump <sha>] [--from <ref>]" >&2; exit 2; }
die() { echo "resolve.sh: $*" >&2; exit 1; }

pkg="" bump="" from=""
while [ $# -gt 0 ]; do
case "$1" in
--package) pkg="${2:-}"; shift 2 ;;
--bump) bump="${2:-}"; shift 2 ;;
--from) from="${2:-}"; shift 2 ;;
*) usage ;;
esac
done
[ -n "$pkg" ] || usage
[[ "$pkg" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || die "'$pkg' is not a valid package directory name"
for tool in gh jq git; do
command -v "$tool" > /dev/null || die "needs $tool"
done

main=origin/main
git rev-parse --verify --quiet "${main}^{commit}" > /dev/null || die "no $main; run: git fetch origin --tags"
json=$(git show "${main}:packages/${pkg}/package.json" 2> /dev/null) ||
die "no packages/${pkg}/package.json on $main. Packages: $(git ls-tree --name-only "$main" packages/ | sed 's|packages/||' | paste -sd' ' -)"
name=$(jq -r .name <<< "$json")
version=$(jq -r .version <<< "$json")
tag="${pkg}-v${version}"

# --- Is this version already released? ---------------------------------------
if git ls-remote --exit-code --tags origin "refs/tags/${tag}" > /dev/null 2>&1; then
die "tag ${tag} already exists on origin: ${name}@${version} is already released. Bump the version first (Actions: Release - Bump package version)."
fi
# A draft has no tag yet, so look at the release list rather than the tags.
if gh release list --repo "$REPO" --limit 200 --json tagName --jq '.[].tagName' | grep -qx "$tag"; then
die "a release for ${tag} already exists (possibly a draft). Publish or delete it first."
fi

# --- The commit the release is pinned to --------------------------------------
if [ -z "$bump" ]; then
# `chore(release): bump @opennsw/x to v1.2.3 (#NN)`, the squash of the bump PR.
pattern="^chore\\(release\\): bump .* to v${version//./\\.}( \\(#[0-9]+\\))?\$"
bump=$(git log "$main" --first-parent -1 --extended-regexp --grep="$pattern" --format=%H -- "packages/${pkg}/package.json")
[ -n "$bump" ] || {
echo "resolve.sh: no 'chore(release): bump … to v${version}' commit touching packages/${pkg}/package.json on $main." >&2
echo "resolve.sh: pass --bump <sha> with the commit to release (it must be on $main)." >&2
exit 3
}
fi
bump=$(git rev-parse --verify "${bump}^{commit}") || die "unknown commit for --bump"
git merge-base --is-ancestor "$bump" "$main" || die "${bump} is not on $main"
at_bump=$(git show "${bump}:packages/${pkg}/package.json" | jq -r .version)
[ "$at_bump" = "$version" ] ||
die "packages/${pkg}/package.json is ${at_bump} at ${bump:0:7} but ${version} on $main; pass --bump with the commit that set ${version}"

# --- The previous release -----------------------------------------------------
prev_tag="" prev_sha=""
if [ -n "$from" ]; then
prev_sha=$(git rev-parse --verify "${from}^{commit}") || die "unknown ref for --from"
prev_tag="$from"
else
# Newest first, pre-releases (1.2.0-beta.1) before their release (1.2.0).
while read -r candidate; do
[ -n "$candidate" ] && [ "$candidate" != "$tag" ] || continue
sha=$(git rev-parse --verify --quiet "${candidate}^{commit}") || continue
if git merge-base --is-ancestor "$sha" "$bump"; then prev_tag="$candidate" prev_sha="$sha"; break; fi
done < <(git -c versionsort.suffix=- tag --list "${pkg}-v*" --sort=-version:refname)
fi
[ -n "$prev_sha" ] || {
echo "resolve.sh: no earlier ${pkg}-v* tag before ${bump:0:7}. Pass --from <ref> with the commit the notes should start after." >&2
exit 3
}

echo "package=${pkg}"
echo "name=${name}"
echo "version=${version}"
echo "tag=${tag}"
echo "bump_sha=${bump}"
echo "prev_tag=${prev_tag}"
echo "prev_sha=${prev_sha}"
Loading
Loading