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 <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. 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/<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 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 <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}" 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 + +<!-- What does this change, and why? --> + +## 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 + +<!-- A short list. Name the package(s) touched: jsonforms-renderers, auth, … --> + +## Testing + +- [ ] `pnpm type-check` +- [ ] `pnpm test` +- [ ] Tried it in a portal or in the package's `dev` playground + +## Release notes + +<!-- +Written for the people who install the package, not for reviewers. It feeds +the package's GitHub Release notes (see CONTRIBUTING.md). + +- Breaking change: say what consumers have to change, e.g. "`x-xml.writeTo` + now needs a leading slash: rename `a/b` to `/a/b`." +- New feature or fix: one sentence on what is now possible or fixed. +- Nothing a consumer would notice (tests, CI, refactor): write N/A and ask for + the `skip-changelog` label. +--> + +N/A + +## Related issues + +<!-- Closes #… --> 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/<dir> 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/<dir> 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/<dir> + // per packages/<dir>/ 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}<package>" 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 # - <package-dir> is the folder name under packages/ # - <semver> must match the "version" in that package's package.json # -# How to cut a release: -# 1. Bump "version" in packages/<package-dir>/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 <package-dir>-v<version>), 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 <package>` +# 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/<dir>, 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 +# <package-dir>-v<version> 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 <<EOF - Bumps \`${{ steps.pkg.outputs.name }}\` (\`${{ steps.pkg.outputs.path }}\`) to \`${{ inputs.version }}\`. + cat > pr-body.md <<EOF + Bumps \`$PKG_NAME\` (\`$PKG_PATH\`) to \`$VERSION\`. - **After merging**, tag the merge commit on \`main\` and push the tag to start the release: + **After merging**, release it from Claude Code with the \`release-package\` skill: \`\`\` - git tag ${{ inputs.package }}-v${{ inputs.version }} - git push origin ${{ inputs.package }}-v${{ inputs.version }} + /release-package $PKG_DIR \`\`\` - Pushing that tag triggers \`release.yml\`, which builds and stages the package on npm. A maintainer then approves it on npm (2FA) to make it live. + The skill collects the PRs merged for this package since its last release, drafts the release notes, and creates a **draft** GitHub Release pinned to the merge commit. Publishing that release creates the tag \`$PKG_DIR-v$VERSION\`, which triggers \`release.yml\` and stages the package on npm. A maintainer then approves it on npm (2FA) to make it live. + + CI does not run automatically on this PR (it is opened with the default \`GITHUB_TOKEN\`); it only changes a version string. _Opened by the \`version-bump.yml\` workflow._ EOF - )" + + url="$(gh pr create \ + --title "chore(release): bump $PKG_NAME to v$VERSION" \ + --base main \ + --head "$BRANCH" \ + --body-file pr-body.md)" + echo "$url" + + # pr-labels.yml adds these too; doing it here keeps the PR correct even + # if that workflow is slow, and a missing label must not fail the bump. + gh pr edit "$url" --add-label skip-changelog --add-label "Package/$PKG_DIR" || + echo "::warning::Could not label $url; pr-labels.yml will." diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5a24eac..e7501d0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,10 +1,54 @@ # Contributing -This is a pnpm monorepo of independent `@opennsw/*` packages. See the [README](README.md) for setup and common commands, and for the [release steps](README.md#releasing). +This is a pnpm monorepo of independent `@opennsw/*` packages. See the [README](README.md) for setup and common commands. + +## Pull requests + +### Title + +`main` takes squash merges only, and the squash commit's subject is the PR title. The **Conventional Commit Title** check ([`pr-title.yml`](.github/workflows/pr-title.yml)) requires a [Conventional Commit](https://www.conventionalcommits.org/) title: + +``` +type(scope): description +``` + +- **type:** `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore` or `revert`. +- **scope:** optional and free-form: a package (`jsonforms-renderers`, `auth`) or a feature area (`xml-export`). +- **`!` before the colon** marks a breaking change: `feat(xml-export)!: write dates in the configured format`. + +### Labels + +Every PR needs two kinds of label so that it lands in the right package's release notes. [`pr-labels.yml`](.github/workflows/pr-labels.yml) adds what it can and fails the **Release notes label** check until the rest is there. + +| Label | Meaning | Set by | +| ------------------ | ------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------- | +| `Package/<dir>` | The PR belongs to `packages/<dir>`. 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/<dir>` label, so it needs `skip-changelog`, or the `Package/<dir>` 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 `<package-dir>-v<version>` (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/<pkg> to v<version>`. 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/<dir>` or changing files under `packages/<dir>/`), 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 <stage-id> # asks for 2FA; the version goes live npm stage reject <stage-id> # 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/<name>/`; the `packages/*` workspace glob picks it up. +2. Name it `@opennsw/<name>`, set `"version"`, and add `publishConfig: { "access": "public" }` and a `repository` entry with `"directory": "packages/<name>"` (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 (`<name>-v<version>`) 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/<name>` 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 (`<package-dir>-v<version>`) 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