diff --git a/.claude/skills/release-package/SKILL.md b/.claude/skills/release-package/SKILL.md
new file mode 100644
index 0000000..19a453e
--- /dev/null
+++ b/.claude/skills/release-package/SKILL.md
@@ -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/
label or files under packages//) 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 `-v`, 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 `), 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
+```
+
+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` commit on `origin/main` → ask the user which commit to release (it must be on `origin/main`), and pass it as `--bump `.
+- No earlier `-v*` tag → ask which commit the notes should start after, and pass it as `--from [`.
+
+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 --from --to > /release-input.md
+```
+
+Read all of it. It lists, in the notes' own format (`* by @author in `), 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//` **or** carries the `Package/` 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 `), and tell the user the PR is unlabelled.
+- _changes packages/\/ but has no Package/\ label_ — included by path; fine, but mention it.
+- _has Package/\ but changes nothing under packages/\/_ — 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: \_ — 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 `) 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 --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/...
+```
+
+- 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 `/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 --repo OpenNSW/ui-packages --draft --target --title --notes-file /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 `` on ``, which starts `release.yml`; it builds and **stages** `@` 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 ` then `npm stage approve `). The job summary of the `release.yml` run has the commands, and `npm stage download ` lets them inspect the tarball first.
+4. Check it is live once approved: `gh run list --workflow release.yml --limit 1` and `npm view @ 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.
diff --git a/.claude/skills/release-package/scripts/collect.sh b/.claude/skills/release-package/scripts/collect.sh
new file mode 100755
index 0000000..4544644
--- /dev/null
+++ b/.claude/skills/release-package/scripts/collect.sh
@@ -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// OR
+# carries the Package/ 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 --from ][ [--to ][] --to defaults to origin/main
+set -euo pipefail
+
+REPO=OpenNSW/ui-packages
+usage() { echo "usage: collect.sh --package --from ][ [--to ][]" >&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("^(?[a-z]+)(\\([^)]*\\))?(?!)?:")? // {type: "", bang: null}) as $t
+ | ($files | map(select(startswith($pp)))) as $own
+ | ($files | map(select(test("^packages/[^/]+/")) | sub("^packages/(?[^/]+)/.*$"; .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
diff --git a/.claude/skills/release-package/scripts/resolve.sh b/.claude/skills/release-package/scripts/resolve.sh
new file mode 100755
index 0000000..b97dadf
--- /dev/null
+++ b/.claude/skills/release-package/scripts/resolve.sh
@@ -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 [--bump ] [--from ][]
+# --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 [--bump ] [--from ][]" >&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 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 ][ 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}"
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..e1199a1
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,40 @@
+## Summary
+
+
+
+## Type of change
+
+- [ ] Bug fix
+- [ ] New feature
+- [ ] Improvement to an existing feature
+- [ ] Breaking change (consumers must change their code)
+- [ ] Docs, tests, refactor or CI only
+
+## Changes made
+
+
+
+## Testing
+
+- [ ] `pnpm type-check`
+- [ ] `pnpm test`
+- [ ] Tried it in a portal or in the package's `dev` playground
+
+## Release notes
+
+
+
+N/A
+
+## Related issues
+
+
diff --git a/.github/release.yml b/.github/release.yml
new file mode 100644
index 0000000..cfa3fb8
--- /dev/null
+++ b/.github/release.yml
@@ -0,0 +1,29 @@
+# Groups the pull request list GitHub generates when someone clicks "Generate
+# release notes" on a release. pr-labels.yml makes sure every PR carries one of
+# these labels, or skip-changelog to leave it out.
+#
+# GitHub's list is NOT scoped to a package: it covers every PR merged since the
+# previous release of any package. For a package release, use the
+# release-package skill (.claude/skills/release-package) instead, which keeps
+# only the PRs that belong to that package.
+changelog:
+ exclude:
+ labels:
+ - skip-changelog
+ categories:
+ - title: Breaking Changes
+ labels:
+ - breaking change
+ - title: New Features
+ labels:
+ - Type/New Feature
+ - title: Improvements
+ labels:
+ - Type/Improvement
+ - title: Bug Fixes
+ labels:
+ - Type/Bug
+ # Type/Task lands here, as does anything that reached main unlabelled.
+ - title: Other Changes
+ labels:
+ - '*'
diff --git a/.github/workflows/pr-labels.yml b/.github/workflows/pr-labels.yml
new file mode 100644
index 0000000..11212bb
--- /dev/null
+++ b/.github/workflows/pr-labels.yml
@@ -0,0 +1,158 @@
+name: PR Labels
+
+# Every PR needs a place in a package's release notes: a Package/ label
+# (which package it belongs to) and a release-notes label (what kind of change
+# it is), or skip-changelog to leave it out. This job fills in what the PR's
+# files, title and author already say, then fails until the labels are there.
+#
+# The release-package skill (.claude/skills/release-package) builds each
+# package's notes from these labels, so a PR without them is easy to miss.
+#
+# pull_request_target, not pull_request: PRs come from forks, and a fork's
+# pull_request token cannot write labels. That is safe only because nothing
+# from the PR is checked out or run — the title, author, file names and labels
+# are read as data in JavaScript, never passed through a shell. Keep it that way.
+
+on:
+ pull_request_target:
+ types: [opened, edited, reopened, synchronize, labeled, unlabeled]
+
+permissions:
+ pull-requests: write # add and remove labels on the PR
+ issues: write # create a Package/ label the first time a package needs one
+
+# Serialised per PR and never cancelled, so each run's check sees the labels
+# the run before it added. Labels added with GITHUB_TOKEN start no new run,
+# which is also why labelling and checking share one job.
+concurrency:
+ group: pr-labels-${{ github.event.pull_request.number }}
+ cancel-in-progress: false
+
+jobs:
+ release-notes-label:
+ name: Release notes label
+ runs-on: ubuntu-latest
+ steps:
+ - name: Label from files, title and author, then require labels
+ uses: actions/github-script@v9
+ with:
+ script: |
+ const BREAKING = 'breaking change';
+ const SKIP = 'skip-changelog';
+ const PACKAGE_PREFIX = 'Package/';
+ const PACKAGE_COLOR = '5319e7';
+ // The labels that place a PR in a package's release notes.
+ const RELEASE_LABELS = [BREAKING, 'Type/New Feature', 'Type/Improvement', 'Type/Bug', 'Type/Task'];
+ // Title types whose section is unambiguous. Anything else is left to a person.
+ const TYPE_LABELS = { feat: 'Type/New Feature', fix: 'Type/Bug' };
+
+ const pr = context.payload.pull_request;
+ const action = context.payload.action;
+ const issue = { ...context.repo, issue_number: pr.number };
+
+ const currentLabels = async () =>
+ new Set((await github.paginate(github.rest.issues.listLabelsOnIssue, { ...issue, per_page: 100 }))
+ .map((label) => label.name));
+ const isPackage = (name) => name.startsWith(PACKAGE_PREFIX);
+ const hasRelease = (labels) => RELEASE_LABELS.some((name) => labels.has(name));
+ const isClassified = (labels) => labels.has(SKIP) || hasRelease(labels);
+
+ const ensurePackageLabel = async (name) => {
+ try {
+ await github.rest.issues.getLabel({ ...context.repo, name });
+ } catch (error) {
+ if (error.status !== 404) throw error;
+ try {
+ await github.rest.issues.createLabel({
+ ...context.repo,
+ name,
+ color: PACKAGE_COLOR,
+ description: `Changes to packages/${name.slice(PACKAGE_PREFIX.length)}`,
+ });
+ } catch (createError) {
+ if (createError.status !== 422) throw createError; // created by a concurrent run
+ }
+ }
+ };
+
+ const before = await currentLabels();
+ const add = new Set();
+
+ // 1. Package labels, from the files the PR changes: one Package/
+ // per packages// it touches. Only ever added, so a label a
+ // person put on a PR that changes nothing in the package stays.
+ if (['opened', 'reopened', 'synchronize'].includes(action)) {
+ const files = await github.paginate(github.rest.pulls.listFiles, {
+ ...context.repo,
+ pull_number: pr.number,
+ per_page: 100,
+ });
+ const dirs = new Set();
+ for (const file of files) {
+ for (const path of [file.filename, file.previous_filename]) {
+ const match = path && /^packages\/([^/]+)\//.exec(path);
+ if (match) dirs.add(match[1]);
+ }
+ }
+ for (const dir of dirs) {
+ const name = PACKAGE_PREFIX + dir;
+ if (before.has(name)) continue;
+ await ensurePackageLabel(name);
+ add.add(name);
+ }
+ }
+
+ // 2. Release-notes labels. The title is applied when the PR opens or
+ // its title changes; otherwise only while the PR has no release-notes
+ // label yet, which also picks up PRs opened before this workflow.
+ // Label events never get here, so a label a person changed stays.
+ const titleChanged = action === 'edited' && context.payload.changes?.title !== undefined;
+ const backfill = (action === 'reopened' || action === 'synchronize') && !isClassified(before);
+ if (action === 'opened' || titleChanged || backfill) {
+ // Bots and agents (Dependabot, the version-bump workflow, claude[bot], …)
+ // and release bumps stay out of the notes; a maintainer can remove the
+ // label to list one.
+ const isBump = /^chore\(release\):/.test(pr.title);
+ if ((pr.user.type === 'Bot' || isBump) && !isClassified(before)) add.add(SKIP);
+
+ const match = /^(\w+)(\([^)]*\))?(!)?:/.exec(pr.title);
+ const typeLabel = match && TYPE_LABELS[match[1]];
+ if (typeLabel) {
+ add.add(typeLabel);
+ for (const other of Object.values(TYPE_LABELS)) {
+ if (other === typeLabel || !before.has(other)) continue;
+ try {
+ await github.rest.issues.removeLabel({ ...issue, name: other });
+ } catch (error) {
+ if (error.status !== 404) throw error; // already removed by someone else
+ }
+ }
+ }
+ if (match && match[3]) add.add(BREAKING);
+ }
+
+ const missing = [...add].filter((name) => !before.has(name));
+ if (missing.length > 0) {
+ await github.rest.issues.addLabels({ ...issue, labels: missing });
+ core.info(`Added: ${missing.join(', ')}`);
+ }
+
+ // 3. Require labels. Read afresh: step 1 or 2, or a person, may have changed them.
+ const labels = await currentLabels();
+ const packages = [...labels].filter(isPackage);
+ if (labels.has(SKIP) && labels.has(BREAKING)) {
+ core.setFailed(`A breaking change must appear in the release notes. Remove "${SKIP}".`);
+ } else if (!isClassified(labels)) {
+ core.setFailed(
+ 'Add a label that places this PR in the release notes: ' +
+ '"Type/New Feature", "Type/Improvement", "Type/Bug" or "Type/Task" ' +
+ `(and "${BREAKING}" if consumers must change their code), or "${SKIP}" to leave it out.`,
+ );
+ } else if (!labels.has(SKIP) && packages.length === 0) {
+ core.setFailed(
+ `This PR changes no file under packages/, so it belongs to no package's release notes. ` +
+ `Add the "${PACKAGE_PREFIX}" label of the package it affects, or "${SKIP}".`,
+ );
+ } else {
+ core.info(`Release notes labels: ${[...labels].filter((name) => name === SKIP || hasRelease(new Set([name])) || isPackage(name)).join(', ')}`);
+ }
diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml
new file mode 100644
index 0000000..e4cdda6
--- /dev/null
+++ b/.github/workflows/pr-title.yml
@@ -0,0 +1,30 @@
+name: PR Title
+
+# main takes squash merges only, and the squash commit's subject is the PR
+# title, so the title is what ends up in the history. It must be a Conventional
+# Commit: `type(scope): description`, with `!` before the colon for a breaking
+# change, e.g.
+#
+# feat(jsonforms-renderers): add x-search.dependsOn
+# fix(auth): refresh the token before it expires
+# feat(xml-export)!: write dates in the configured format
+#
+# The type also drives the release-notes label (pr-labels.yml). The scope is
+# optional and free-form (a package or a feature area). Types: feat, fix, docs,
+# style, refactor, perf, test, build, ci, chore, revert.
+
+on:
+ pull_request:
+ types: [opened, edited, synchronize, reopened]
+
+permissions:
+ pull-requests: read
+
+jobs:
+ title:
+ name: Conventional Commit Title
+ runs-on: ubuntu-latest
+ steps:
+ - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 14820b3..4711593 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -14,14 +14,23 @@ name: Release - Stage @opennsw package on npm
# - is the folder name under packages/
# - must match the "version" in that package's package.json
#
-# How to cut a release:
-# 1. Bump "version" in packages//package.json and merge to main.
-# 2. Tag that commit and push the tag, e.g.:
-# git tag jsonforms-renderers-v0.3.1
-# git push origin jsonforms-renderers-v0.3.1
-# 3. This workflow resolves the package from the tag, verifies the version,
-# builds, and stages the package on npm.
+# How to cut a release (see CONTRIBUTING.md):
+# 1. Run "Release - Bump package version" and merge the PR it opens.
+# 2. Run the release-package skill in Claude Code. It creates a DRAFT GitHub
+# Release, pinned to the bump commit, with notes for that package only.
+# 3. Publish the draft on GitHub. That creates the tag, which triggers this
+# workflow: it resolves the package from the tag, verifies the version and
+# that the commit is on main, builds, and stages the package on npm.
# 4. A maintainer approves the staged package on npm (2FA). Only then is it live.
+# A tag can also be pushed by hand (git tag -v), with the
+# same checks.
+#
+# A version that is already on npm is skipped, not failed, so re-running is safe
+# once it has been approved. A version that is staged but not approved yet is not
+# on npm, so a re-run would stage it again: check `npm stage list `
+# first, and reject the older stage.
+# Pre-release versions (1.2.0-beta.1) stage under the "next" dist-tag so they
+# never become "latest".
#
# Authentication: npm Trusted Publishing via GitHub OIDC — no NPM_TOKEN secret.
# One-time setup per package on npmjs.com: open the package's Settings ->
@@ -51,6 +60,18 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
+ with:
+ fetch-depth: 0 # full history, to check the tagged commit is on main
+
+ # The release-package skill pins its draft to the bump commit on main, but
+ # anyone can push a tag. Never publish a commit that isn't on main.
+ - name: Check the tagged commit is on main
+ run: |
+ git fetch --no-tags origin main
+ if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
+ echo "::error::Tag $GITHUB_REF_NAME points at $GITHUB_SHA, which is not on main. Merge it first, then tag the merge commit."
+ exit 1
+ fi
- name: Setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
@@ -110,35 +131,67 @@ jobs:
echo "::error::Tag version ($TAG_VERSION) does not match $PKG_PATH/package.json ($PKG_VERSION)."
exit 1
fi
+ # npm 11 refuses to stage or publish a pre-release without an explicit
+ # tag, and a pre-release must not become "latest" anyway.
+ DIST_TAG=latest
+ if [[ "$PKG_VERSION" == *-* ]]; then DIST_TAG=next; fi
+ echo "Dist-tag: $DIST_TAG"
{
echo "path=$PKG_PATH"
echo "name=$PKG_NAME"
echo "version=$PKG_VERSION"
+ echo "dist_tag=$DIST_TAG"
} >> "$GITHUB_OUTPUT"
+ # Makes a re-run (or a tag pushed for a version that was published by hand
+ # earlier) a no-op instead of a failed run. It only sees approved versions:
+ # one that is staged and waiting for approval is not on npm yet.
+ - name: Check whether this version is already on npm
+ id: published
+ # From the package directory: the root package.json's devEngines makes npm
+ # refuse to run there (the staging steps below run here for the same reason).
+ working-directory: ${{ steps.pkg.outputs.path }}
+ env:
+ PKG_NAME: ${{ steps.pkg.outputs.name }}
+ PKG_VERSION: ${{ steps.pkg.outputs.version }}
+ run: |
+ if npm view "$PKG_NAME@$PKG_VERSION" version > /dev/null 2>&1; then
+ echo "exists=true" >> "$GITHUB_OUTPUT"
+ echo "::notice::$PKG_NAME@$PKG_VERSION is already on npm; nothing to stage."
+ else
+ echo "exists=false" >> "$GITHUB_OUTPUT"
+ fi
+
- name: Type check
+ if: steps.published.outputs.exists != 'true'
run: pnpm --filter "${{ steps.pkg.outputs.name }}" run type-check
- name: Build package
+ if: steps.published.outputs.exists != 'true'
run: pnpm --filter "${{ steps.pkg.outputs.name }}" run build
# Sanity-check the tarball (no real upload).
- name: Stage dry run
+ if: steps.published.outputs.exists != 'true'
working-directory: ${{ steps.pkg.outputs.path }}
- run: npm stage publish --provenance --access public --dry-run
+ env:
+ DIST_TAG: ${{ steps.pkg.outputs.dist_tag }}
+ run: npm stage publish --provenance --access public --tag "$DIST_TAG" --dry-run
# Uploads the tarball to npm's staging queue. Nothing is installable until a
# maintainer approves it on npm, so a green run here does NOT mean the
# version is live. The output (it carries the stage ID) goes to the job
# summary, with the commands the approver needs.
- name: Stage on npm
+ if: steps.published.outputs.exists != 'true'
working-directory: ${{ steps.pkg.outputs.path }}
env:
+ DIST_TAG: ${{ steps.pkg.outputs.dist_tag }}
PKG_NAME: ${{ steps.pkg.outputs.name }}
PKG_VERSION: ${{ steps.pkg.outputs.version }}
run: |
set -o pipefail
- npm stage publish --provenance --access public 2>&1 | tee "$RUNNER_TEMP/stage.log"
+ npm stage publish --provenance --access public --tag "$DIST_TAG" 2>&1 | tee "$RUNNER_TEMP/stage.log"
{
echo "### Staged $PKG_NAME@$PKG_VERSION on npm"
echo
diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml
index f920d09..e8693a2 100644
--- a/.github/workflows/version-bump.yml
+++ b/.github/workflows/version-bump.yml
@@ -3,23 +3,25 @@ name: Release - Bump package version
# Manually triggered: bumps "version" in a package's package.json and opens a
# PR against main, so cutting a release no longer needs a hand-written PR.
#
-# How to cut a release with this workflow:
+# How to cut a release (see CONTRIBUTING.md):
# 1. Actions tab -> "Release - Bump package version" -> Run workflow.
# Fill in the package directory (e.g. jsonforms-renderers) and the new
-# version (e.g. 0.4.1, no leading "v").
-# 2. Review and merge the PR it opens.
-# 3. Tag the merge commit on main and push the tag, e.g.:
-# git tag jsonforms-renderers-v0.4.1
-# git push origin jsonforms-renderers-v0.4.1
-# That tag push triggers release.yml, which builds and stages the package on
-# npm.
-# 4. A maintainer approves the staged package on npm (2FA). Only then is it live.
+# version (e.g. 0.12.0, no leading "v").
+# 2. Review and merge the PR it opens (the PR is labelled skip-changelog and
+# Package/, so it stays out of the release notes).
+# 3. In Claude Code, run the release-package skill for that package. It drafts
+# the release notes from the PRs merged for that package since its last
+# release and creates a DRAFT GitHub Release pinned to the bump commit.
+# 4. Review the draft on GitHub and press Publish. That creates the
+# -v tag, which triggers release.yml, which builds and
+# STAGES the package on npm.
+# 5. A maintainer approves the staged package on npm (2FA). Only then is it live.
#
# Note: this workflow pushes/opens its PR using the default GITHUB_TOKEN, so
# packages-ci.yml will NOT run on the PR it creates (GitHub doesn't let the
# default token trigger other workflows, to avoid recursive runs). That's
# fine here since the PR only ever changes a version string — review the
-# diff by eye before merging.
+# diff by eye before merging, or re-run CI from the Actions tab.
on:
workflow_dispatch:
@@ -30,7 +32,7 @@ on:
default: 'jsonforms-renderers'
type: string
version:
- description: 'New version to set, semver without a leading "v" (e.g. 0.4.1)'
+ description: 'New version to set, semver without a leading "v" (e.g. 0.12.0)'
required: true
type: string
@@ -51,11 +53,38 @@ jobs:
uses: actions/checkout@v4
# Fail early with a clear message rather than partway through a git push.
+ # The inputs reach the shell through env, never by interpolation into the
+ # script text, and are checked against strict patterns before any use.
- name: Validate inputs
id: pkg
+ env:
+ PKG_DIR: ${{ inputs.package }}
+ VERSION: ${{ inputs.version }}
run: |
- PKG_DIR="${{ inputs.package }}"
- VERSION="${{ inputs.version }}"
+ # True when $1 has higher semver precedence than $2. A release outranks
+ # its own pre-releases; build metadata is ignored.
+ version_gt() {
+ local a="${1%%+*}" b="${2%%+*}" a_pre="" b_pre=""
+ [[ "$a" == *-* ]] && a_pre="${a#*-}"
+ [[ "$b" == *-* ]] && b_pre="${b#*-}"
+ local a_core="${a%%-*}" b_core="${b%%-*}"
+ if [ "$a_core" != "$b_core" ]; then
+ [ "$(printf '%s\n%s\n' "$a_core" "$b_core" | sort -V | tail -n1)" = "$a_core" ]
+ elif [ "$a_pre" = "$b_pre" ]; then
+ return 1
+ elif [ -z "$a_pre" ]; then
+ return 0
+ elif [ -z "$b_pre" ]; then
+ return 1
+ else
+ [ "$(printf '%s\n%s\n' "$a_pre" "$b_pre" | sort -V | tail -n1)" = "$a_pre" ]
+ fi
+ }
+
+ if [[ ! "$PKG_DIR" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then
+ echo "::error::'$PKG_DIR' is not a valid package directory name."
+ exit 1
+ fi
PKG_PATH="packages/$PKG_DIR"
if [ ! -f "$PKG_PATH/package.json" ]; then
@@ -69,56 +98,88 @@ jobs:
fi
CURRENT_VERSION="$(node -p "require('./$PKG_PATH/package.json').version")"
- if [ "$VERSION" == "$CURRENT_VERSION" ]; then
- echo "::error::$PKG_PATH is already at version $VERSION."
+ if ! version_gt "$VERSION" "$CURRENT_VERSION"; then
+ echo "::error::$VERSION is not higher than the current version of $PKG_PATH ($CURRENT_VERSION)."
exit 1
fi
PKG_NAME="$(node -p "require('./$PKG_PATH/package.json').name")"
BRANCH="release/$PKG_DIR-v$VERSION"
+ TAG="$PKG_DIR-v$VERSION"
+
+ if git ls-remote --exit-code --heads origin "$BRANCH" > /dev/null; then
+ echo "::error::Branch $BRANCH already exists. Merge or delete the earlier bump PR first."
+ exit 1
+ fi
+ if git ls-remote --exit-code --tags origin "refs/tags/$TAG" > /dev/null; then
+ echo "::error::Tag $TAG already exists, so $VERSION of $PKG_NAME is already released."
+ exit 1
+ fi
echo "Package path: $PKG_PATH"
echo "Package name: $PKG_NAME"
echo "Current version: $CURRENT_VERSION"
echo "New version: $VERSION"
- echo "path=$PKG_PATH" >> "$GITHUB_OUTPUT"
- echo "name=$PKG_NAME" >> "$GITHUB_OUTPUT"
- echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
+ {
+ echo "path=$PKG_PATH"
+ echo "name=$PKG_NAME"
+ echo "branch=$BRANCH"
+ } >> "$GITHUB_OUTPUT"
- name: Bump version in package.json
working-directory: ${{ steps.pkg.outputs.path }}
- run: npm pkg set version="${{ inputs.version }}"
+ env:
+ VERSION: ${{ inputs.version }}
+ run: npm pkg set version="$VERSION"
- name: Commit and push branch
+ env:
+ PKG_PATH: ${{ steps.pkg.outputs.path }}
+ PKG_NAME: ${{ steps.pkg.outputs.name }}
+ BRANCH: ${{ steps.pkg.outputs.branch }}
+ VERSION: ${{ inputs.version }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- git checkout -b "${{ steps.pkg.outputs.branch }}"
- git add "${{ steps.pkg.outputs.path }}/package.json"
- git commit -m "chore(release): bump ${{ steps.pkg.outputs.name }} to v${{ inputs.version }}"
- git push origin "${{ steps.pkg.outputs.branch }}"
+ git checkout -b "$BRANCH"
+ git add "$PKG_PATH/package.json"
+ git commit -m "chore(release): bump $PKG_NAME to v$VERSION"
+ git push origin "$BRANCH"
- name: Open pull request
env:
GH_TOKEN: ${{ github.token }}
+ PKG_DIR: ${{ inputs.package }}
+ PKG_PATH: ${{ steps.pkg.outputs.path }}
+ PKG_NAME: ${{ steps.pkg.outputs.name }}
+ BRANCH: ${{ steps.pkg.outputs.branch }}
+ VERSION: ${{ inputs.version }}
run: |
- gh pr create \
- --title "chore(release): bump ${{ steps.pkg.outputs.name }} to v${{ inputs.version }}" \
- --base main \
- --head "${{ steps.pkg.outputs.branch }}" \
- --body "$(cat < pr-body.md <` | The PR belongs to `packages/`. One per package the PR changes. | Automatic, from the changed files |
+| `Type/New Feature` | Release notes: **New Features** | Automatic for `feat` |
+| `Type/Bug` | Release notes: **Bug Fixes** | Automatic for `fix` |
+| `Type/Improvement` | Release notes: **Improvements** | A person |
+| `Type/Task` | Release notes: **Other Changes** | A person |
+| `breaking change` | Release notes: **Breaking Changes**. Add it next to another label whenever consumers must change code. | Automatic for `!` |
+| `skip-changelog` | Left out of the notes: tests, CI, refactors, anything a consumer won't notice. Not allowed with `breaking change`. | Automatic for bots and `chore(release)` bumps; otherwise a person |
+
+A PR that changes nothing under `packages/` has no `Package/` label, so it needs `skip-changelog`, or the `Package/` label of the package it affects added by hand.
+
+The checks are advisory until a repository admin makes them required in the branch ruleset.
+
+### Release notes section
+
+The PR template has a **Release notes** section. Write it for the people who install the package: for a breaking change, what they have to change (`x-xml.writeTo` now needs a leading slash: rename `a/b` to `/a/b`); for a feature or fix, one sentence. The release skill reads it when it drafts the notes. Write `N/A` for changes nobody outside the repo would notice, and label them `skip-changelog`.
## Releasing a package
-Packages reach npm with [Trusted Publishing](https://docs.npmjs.com/trusted-publishers) (OIDC, no token secret) and build provenance, through [staged publishing](https://docs.npmjs.com/staged-publishing/): CI can only _stage_ a version, and a maintainer approves it on npm with 2FA before it is installable. When a release tag is pushed, [`release.yml`](.github/workflows/release.yml) builds the package and stages it.
+Each package is versioned and released on its own, with a tag `-v` (for example `jsonforms-renderers-v0.11.0`). Packages reach npm with [Trusted Publishing](https://docs.npmjs.com/trusted-publishers) (OIDC, no token secret) and build provenance, through [staged publishing](https://docs.npmjs.com/staged-publishing/): CI can only _stage_ a version, and a maintainer approves it on npm with 2FA before it is installable.
+
+1. **Bump.** Actions → **Release - Bump package version** → _Run workflow_, with the package directory (`jsonforms-renderers`) and the new version (`0.12.0`, no `v`). It opens `chore(release): bump @opennsw/ to v`. While a package is on 0.x, a breaking change or a new feature bumps the minor version and fixes alone bump the patch.
+2. **Merge** the bump PR once it is approved. CI doesn't start automatically on a PR opened by the workflow's token; re-run it from the Actions tab if you want it.
+3. **Release.** In Claude Code, from this repo, run the **`release-package`** skill for the package (`/release-package jsonforms-renderers`). It finds the package's previous release, collects only the PRs that belong to the package (labelled `Package/` or changing files under `packages//`), drafts the notes, and after you confirm them creates a **draft** GitHub Release pinned to the bump commit. It never publishes anything.
+4. **Publish.** Read the draft on GitHub, edit it if needed, and press **Publish**. That creates the tag, which starts [`release.yml`](.github/workflows/release.yml): it checks the tag matches `package.json` and is on `main`, builds, and **stages** the package on npm. Pre-release versions (`1.2.0-beta.1`) stage under the `next` dist-tag. A version that is already on npm is skipped, so re-running an approved release is safe.
+5. **Approve on npm.** A maintainer approves the staged package (see below). Only then is the version live.
+
+Before step 3, make sure PRs in the release carry the right labels. The skill lists any that are missing or look wrong.
### Approving a staged release
@@ -17,7 +61,7 @@ npm stage approve # asks for 2FA; the version goes live
npm stage reject # discard it instead
```
-Or use the **Staged Packages** tab of the package on npmjs.com. Look at the tarball before approving: it should contain `dist`, `README.md` and `package.json`, and the code should match the tag. Prefer an approver other than whoever pushed the tag; npm doesn't enforce that. Check afterwards that the approved version carries its provenance badge.
+Or use the **Staged Packages** tab of the package on npmjs.com. Look at the tarball before approving: it should contain `dist`, `README.md` and `package.json`, and the code should match the tag. Prefer an approver other than whoever published the GitHub release; npm doesn't enforce that. Check afterwards that the approved version carries its provenance badge.
Don't re-run a release while its version is still staged: a re-run would stage it again. Reject the old stage first.
@@ -27,3 +71,13 @@ Set these on npmjs.com, in the package's settings, before its first staged relea
- **Trusted Publisher:** GitHub Actions, repository `OpenNSW/ui-packages`, workflow `release.yml`, with **Allowed actions** set to `npm stage publish` only (not `npm publish`), so CI can stage a version but never make it live. `release.yml` can't stage the package until this is done.
- **Publishing access:** require two-factor authentication and disallow tokens, so nothing else can publish.
+
+### Adding a package
+
+1. Create `packages//`; the `packages/*` workspace glob picks it up.
+2. Name it `@opennsw/`, set `"version"`, and add `publishConfig: { "access": "public" }` and a `repository` entry with `"directory": "packages/"` (provenance fails if the repository URL doesn't match this repo).
+3. Define **`build`** and **`type-check`** scripts: CI runs them with `pnpm --recursive`, which silently skips a package without them.
+4. Configure the package on npmjs.com as described in [npm settings for a package](#npm-settings-for-a-package). `release.yml` can't stage the package until this is done.
+5. Publish the first version, then tag that commit (`-v`) so the next release's notes have a starting point. Without an earlier tag the release skill asks which commit to start from.
+6. Keep the package's `README.md` short, since it is what npm shows: what it is, how to install and use it, and absolute links to the details in this repo. `docs/` is not part of the published tarball.
+7. Copy the root [`LICENSE`](LICENSE) into the package directory and set `"license": "Apache-2.0"` in its `package.json`. npm ships a `LICENSE` file from the package root even when `files` doesn't list it, and a tarball without one has no licence text.
diff --git a/README.md b/README.md
index 1b1fea5..daaddb2 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,9 @@ A pnpm monorepo of the shared UI packages that make up the **OpenNSW Framework**
├── eslint.config.js # shared, repo-wide ESLint flat config
├── .prettierrc # shared Prettier config
├── pnpm-workspace.yaml # workspace globs (packages/*)
-└── .github/workflows/ # CI + release automation
+├── .github/workflows/ # CI, PR checks and release automation
+├── .claude/skills/ # Claude Code skills (release-package)
+└── CONTRIBUTING.md # PR titles, labels and the release procedure
```
Tooling is centralized at the root so every package shares one lint, format, and TypeScript setup.
@@ -60,24 +62,24 @@ pnpm --filter @opennsw/jsonforms-renderers run dev
2. Scope the package name as `@opennsw/` and set `"version"`.
3. Define **`build`** and **`type-check`** scripts in its `package.json`. CI runs these with `pnpm --recursive`, which _silently skips_ packages that lack them — so without these scripts the package builds green but goes unverified.
4. No ESLint or Prettier setup is needed: the root `eslint.config.js` (with `projectService`) and `.prettierrc` cover all `packages/**` automatically.
-5. For publishing, add a `publishConfig: { "access": "public" }` (scoped packages are private by default) and a release workflow modeled on [`jsonforms-renderers-release.yaml`](.github/workflows/jsonforms-renderers-release.yaml).
+5. To publish it, follow [Adding a package](CONTRIBUTING.md#adding-a-package): register the npm Trusted Publisher and tag the first release. The shared [`release.yml`](.github/workflows/release.yml) stages every package.
## Continuous integration
[`packages-ci.yml`](.github/workflows/packages-ci.yml) runs on pull requests that touch `packages/**` or the shared root config. It performs type-check → lint → format check → build, then a security stage (dependency review + `pnpm audit`). Lint and the security checks are currently non-blocking (`continue-on-error`); type-check, formatting, and build must pass.
+Every PR also gets a **Conventional Commit Title** check ([`pr-title.yml`](.github/workflows/pr-title.yml)) and a **Release notes label** check ([`pr-labels.yml`](.github/workflows/pr-labels.yml)), which labels the PR with its package and change type. See [CONTRIBUTING.md](CONTRIBUTING.md).
+
## Releasing
-Each package is released independently via its own tag-triggered workflow, using npm [Trusted Publishing](https://docs.npmjs.com/trusted-publishers) (OIDC, no token secret) and [staged publishing](https://docs.npmjs.com/staged-publishing/): CI stages the version, and a maintainer approves it on npm with 2FA before it is installable. For `@opennsw/jsonforms-renderers`:
+Each package is versioned, tagged (`-v`) and released to npm on its own, with [Trusted Publishing](https://docs.npmjs.com/trusted-publishers) (OIDC, no token secret) and [staged publishing](https://docs.npmjs.com/staged-publishing/): CI stages the version and a maintainer approves it on npm with 2FA. In short:
+
+1. Run **Release - Bump package version** in the Actions tab and merge the PR it opens.
+2. Run the `release-package` skill in Claude Code: it drafts the package's release notes from only the PRs that belong to it and creates a draft GitHub Release.
+3. Publish the draft on GitHub. That creates the tag, and [`release.yml`](.github/workflows/release.yml) stages the package on npm.
+4. A maintainer approves the staged package on npm (2FA). Only then is it live.
-1. Bump `"version"` in `packages/jsonforms-renderers/package.json` and merge to `main`.
-2. Tag the commit and push the tag:
- ```bash
- git tag jsonforms-renderers-v0.3.1
- git push origin jsonforms-renderers-v0.3.1
- ```
-3. The release workflow verifies the tag matches `package.json`, builds, and stages the package on npm with provenance.
-4. A maintainer approves the staged package on npm; only then is it live. See [Approving a staged release](CONTRIBUTING.md#approving-a-staged-release).
+The full procedure, the PR labels it relies on, and how to add a package are in [CONTRIBUTING.md](CONTRIBUTING.md).
## License
]