diff --git a/.claude/skills/release-pr/SKILL.md b/.claude/skills/release-pr/SKILL.md new file mode 100644 index 00000000..4701929d --- /dev/null +++ b/.claude/skills/release-pr/SKILL.md @@ -0,0 +1,167 @@ +--- +name: release-pr +description: > + Open a release PR for stream-core-flutter: bump the version(s) of one or more packages, finalise their + hand-curated CHANGELOGs (promote `## Upcoming` → `## X.Y.Z`), and open a PR from a `release/` branch. Per-package + independent versioning — release one package or several in a single PR. +disable-model-invocation: true +argument-hint: "[ ...]" +arguments: [packages] +allowed-tools: + - Bash(git *) + - Bash(gh *) + - Bash(melos *) + - Bash(which *) + - Bash(grep *) + - Bash(sed *) + - Read + - Edit + - Write +--- + +# release-pr + +Opens a release PR for stream-core-flutter. Branch `release/<...>` → base `main` → title +`chore(): release vX.Y.Z` (single package) or `chore(repo): release packages` (multiple). + +**This skill only opens the PR.** After merge, tagging and pub.dev publishing are automatic: +[`release_tag.yml`](../../../.github/workflows/release_tag.yml) tags every bumped package (`-vX.Y.Z`) and +[`release_publish.yml`](../../../.github/workflows/release_publish.yml) publishes each and cuts a GitHub Release. +See the "Releasing" section of `STYLE_GUIDE.md`. + +## Key facts for this repo + +- **Independent per-package versioning.** Each package releases on its own tag `-vX.Y.Z`. A single release PR + may bump **one package or several** — each still gets its own tag + publish run. +- **CHANGELOGs are hand-curated.** Never run `melos version` — it regenerates changelog entries from commit messages + and clobbers the curated `## Upcoming` bullets. Releasing means *promoting* the existing `## Upcoming` heading to + `## X.Y.Z`, not rewriting it. +- **`release/` branch is required**, not a convention: the changelog-placement check in `pr_title.yml` only allows a + `## Upcoming` heading to become `## X.Y.Z` on a `release/` branch. + +Publishable packages are the non-private ones under `packages/*` — list them with +`melos list --no-private`. Their conventional-commit scopes are defined in +`.github/workflows/pr_title.yml` (the `semantic_changelog_update` job maps each +scope to a package path); read that map rather than hard-coding it, so adding a +package needs no change here: + +```bash +grep -A12 'semantic_changelog_update' .github/workflows/pr_title.yml +``` + +## Inputs + +1. **Which packages + versions.** If given as args (e.g. `/release-pr stream_core 0.4.1 stream_core_flutter 0.5.0`), + use them; strip any leading `v`. Otherwise **detect and confirm**: a package needs releasing when its + `CHANGELOG.md` has a non-empty `## Upcoming` section. List those and ask the user for each new version (they pick + the semver bump; don't infer it). +2. **Base branch** is always `main`. + +## Pre-flight + +Run these. **If any fails, stop, surface it to the user, and do not auto-fix** (no stashing, no force-pull, no +killing processes). + +- `git checkout main && git pull --ff-only` leaves `git status --short` clean — **including untracked files**, so a + stray local file can't slip into the release commit at `git add -A` (step 5). +- `which melos`, `gh auth status` succeed. +- Latest CI on `main` is green: `gh run list --branch main --limit 5` — no failures on the most recent runs. +- No open release PR for the same branch: `gh pr list --head --state all --json number` returns `[]`. + +## Steps + +### 1. Branch off main + +```bash +git checkout -b +``` + +Branch name: `release/-vX.Y.Z` for a single package, or `release/YYYY-MM-DD` for a multi-package release. + +### 2. Bump version(s) + +For **each** package being released: + +- Set `version: ` in `packages//pubspec.yaml`. + +Only if a released package is a **dependency that a dependent must now require at the new version** (the dependent +started using a new API), also bump that package's entry in `melos.yaml`'s `command.bootstrap.dependencies` block +(`grep -nE 'stream_(core|core_flutter|thumbnail):' melos.yaml`) — and release the dependent too. A compatible bump +that the existing caret already allows (e.g. `stream_core 0.4.0 → 0.4.1` under `stream_core: ^0.4.0`) needs **no** +block change. + +Then propagate constraints: + +```bash +melos bootstrap +``` + +Do **not** run `melos version`. + +### 3. Finalise each released package's CHANGELOG + +For every package being released, in `packages//CHANGELOG.md` rename the top `## Upcoming` heading to +`## `. Keep the curated bullets exactly as they are — do not add, rewrite, or regenerate them. Sub-headings +(`### ✨ Features`, `### 🐛 Bug Fixes`, `### 🛑 Breaking / Removals`) stay untouched. + +If a package is being released only because a dependency bump forces it (no user-facing change of its own), give it a +`## ` section with a single bullet noting the dependency bump — every released package must have a non-empty +`## ` section (pana fails on an empty or missing one). + +Do not hand-write cross-package "bumps stream_core to X.Y.Z" lines beyond that; per `STYLE_GUIDE.md`, cross-linking is +the release tooling's job. + +### 4. Sanity-check + +```bash +melos run analyze +melos run lint:pub +``` + +If either fails, surface it and stop. + +### 5. Commit and push + +```bash +git add -A +git commit -m "" +git push -u origin <branch> +``` + +Single commit. **The title is load-bearing** — `release_tag.yml` gates on the `chore(...): release` prefix: + +- One package: `chore(<scope>): release <package> vX.Y.Z` (e.g. `chore(llc): release stream_core v0.4.1`). +- Several: `chore(repo): release packages` — generic, so the title stays short no matter how many packages bump. + +Tagging derives from package state, not this title, so a typo can't mis-tag — but keep the prefix intact or the tag +job won't fire. + +### 6. Open the PR + +Build the body from the promoted CHANGELOG sections (the same content that becomes each GitHub Release). Do **not** +use `gh api .../generate-notes` — this repo deliberately does not use GitHub's generated notes. + +```bash +gh pr create --base main --head <branch> --title "<title>" --body-file <notes> +``` + +A good body lists each released package, its version, and its `## <newver>` CHANGELOG section. Return the PR URL. + +**Tell the user to squash-merge it.** `release_tag.yml` gates on the *tip* commit's message, so a squash lands the +`chore(...): release` title as that commit. A merge commit would make the tip `Merge pull request #…` and the release +would silently not run. + +## After merge (FYI) + +`release_tag.yml` tags every bumped package and `release_publish.yml` publishes each (OIDC) and creates a per-package +GitHub Release from its CHANGELOG section. Multi-package releases publish in dependency order automatically (the +publish job waits for in-workspace dependencies to be live first). + +## Don't + +- **Never run `melos version`** — it clobbers the hand-curated CHANGELOGs. +- **Never tag or push a tag** — `release_tag.yml` does it on merge. +- **Never run `melos run release:pub` locally** — it's the CI publish step; running it publishes from an unreviewed + tree. Refuse even if asked. (Tagging is inlined in `release_tag.yml`, not a melos script — don't run it by hand.) +- **Never create a GitHub release** (`gh release create`) — `release_publish.yml` creates it after the tag is pushed. +- **Never merge the PR.** Return the URL and stop. diff --git a/.github/workflows/release_publish.yml b/.github/workflows/release_publish.yml new file mode 100644 index 00000000..d7a66a40 --- /dev/null +++ b/.github/workflows/release_publish.yml @@ -0,0 +1,206 @@ +name: release_publish + +on: + push: + tags: + # <pkg>-vX.Y.Z plus any pre-release (-…) or build (+…) suffix — matches + # pub.dev's suggested OIDC tag pattern; the parse step validates the rest. + - '*-v[0-9]+.[0-9]+.[0-9]+*' + workflow_dispatch: # manual re-runs against a tag ref + +concurrency: + # false: don't let a re-run cancel an in-flight publish of the same tag. + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + permissions: + contents: write # create the GitHub Release + id-token: write # OIDC auth to pub.dev + runs-on: ubuntu-latest + steps: + - name: 📚 Checkout branch + # Default GITHUB_TOKEN: this job never pushes to git (the release step + # passes its own token), so no bot PAT sits in git config during setup. + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: 🏷️ Parse package and version from tag + id: parse + shell: bash + run: | + set -euo pipefail + + ref="${GITHUB_REF#refs/tags/}" + echo "📦 Tag: $ref" + + # <package>-v<semver>, with optional pre-release (-…) and build (+…) suffixes. + if [[ ! "$ref" =~ ^([a-z0-9_]+)-v([0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?)$ ]]; then + echo "::error ::Tag '$ref' does not match '<package>-v<semver>'." + exit 1 + fi + + pkg="${BASH_REMATCH[1]}" + version="${BASH_REMATCH[2]}" + + # Find the package's pubspec by name — melos isn't installed yet, and a + # package's directory may differ from its name. + pubspec="$(grep -rlE "^name:[[:space:]]+$pkg\$" --include=pubspec.yaml packages/ 2>/dev/null | head -n1)" + if [[ -z "$pubspec" ]]; then + echo "::error ::No package named '$pkg' found under packages/." + exit 1 + fi + + # Guard a stray tag: pubspec version must equal the tag version. + pubspec_version="$(grep -E '^version:' "$pubspec" | head -n1 | sed -E 's/^version:[[:space:]]*//')" + if [[ "$pubspec_version" != "$version" ]]; then + echo "::error ::Tag version ($version) does not match $pubspec version ($pubspec_version)." + exit 1 + fi + + # Pre-release = a hyphen suffix, ignoring any build-metadata (+…) part. + is_prerelease=$([[ "${version%%+*}" == *-* ]] && echo true || echo false) + + { + echo "package=$pkg" + echo "version=$version" + echo "prerelease=$is_prerelease" + } >> "$GITHUB_OUTPUT" + + - name: 🎯 Setup Dart + # Before Flutter: provides the pub.dev OIDC token. + uses: dart-lang/setup-dart@v1 + + - name: 🐦 Install Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + + - name: 📦 Install Tools + run: flutter pub global activate melos + + - name: 🔧 Bootstrap Workspace + run: melos bootstrap --verbose + + - name: 🌵 Dry Run + # MELOS_PACKAGES scopes lint:pub to the tagged package. + run: melos run lint:pub + env: + MELOS_PACKAGES: ${{ steps.parse.outputs.package }} + + - name: ⏳ Wait for in-workspace dependencies + # A dependent can reach pub.dev before its dependency is indexed (each + # runs separately), which the server rejects. Wait via the per-version + # endpoint — fresh in CI, unlike the listing `melos --published` reads. + shell: bash + env: + PKG: ${{ steps.parse.outputs.package }} + run: | + set -euo pipefail + + # Deps from the graph, versions from list --json. Skip private deps — + # they never publish, so waiting on one would hang until timeout. + info="$(melos list --json 2>/dev/null)" + raw="$(melos list --graph 2>/dev/null | awk '/^\{/{f=1} f{print} /^\}$/{f=0}' \ + | jq -r --arg p "$PKG" '.[$p] // [] | .[]')" + + deps="" + for dep in $raw; do + priv="$(printf '%s' "$info" | jq -r --arg n "$dep" '.[] | select(.name==$n) | .private')" + if [ "$priv" != "true" ]; then deps="$deps $dep"; fi + done + + [[ -z "${deps// /}" ]] && { echo "✅ $PKG has no publishable in-workspace dependencies."; exit 0; } + + for dep in $deps; do + want="$(printf '%s' "$info" | jq -r --arg n "$dep" '.[] | select(.name==$n) | .version')" + enc="${want//+/%2B}" # url-encode a build-metadata '+' + echo "⏳ Waiting for $dep v$want on pub.dev…" + deadline=$((SECONDS + 900)) # 15 minutes + until curl -sfL --connect-timeout 10 --max-time 30 -o /dev/null "https://pub.dev/api/packages/$dep/versions/$enc"; do + (( SECONDS < deadline )) || { echo "::error ::Timed out after 15m waiting for $dep v$want on pub.dev. If $dep is a new package, publish it (enable automated publishing) before releasing packages that depend on it; otherwise re-run this once $dep v$want is live."; exit 1; } + echo " …not live yet; retrying in 15s" + sleep 15 + done + echo "✅ $dep v$want is live." + done + + - name: 📢 Publish to pub.dev + # Idempotent by pub.dev state, not `melos --no-published` (which lags in + # CI): skip if the version is already live, else publish and confirm it + # landed before the release is cut. + shell: bash + env: + PKG: ${{ steps.parse.outputs.package }} + VERSION: ${{ steps.parse.outputs.version }} + MELOS_PACKAGES: ${{ steps.parse.outputs.package }} + run: | + set -euo pipefail + enc="${VERSION//+/%2B}" # url-encode a build-metadata '+' + url="https://pub.dev/api/packages/$PKG/versions/$enc" + + # Already live? Nothing to do — a genuinely idempotent re-run. + if curl -sfL --connect-timeout 10 --max-time 30 -o /dev/null "$url"; then + echo "✅ $PKG v$VERSION is already on pub.dev; nothing to publish." + exit 0 + fi + + echo "📦 Publishing $PKG v$VERSION…" + melos run release:pub + + # Confirm it actually landed before the release is cut (guards a no-op + # publish from producing a Release for a version that isn't on pub.dev). + echo "⏳ Confirming $PKG v$VERSION is live…" + deadline=$((SECONDS + 300)) # 5 minutes + until curl -sfL --connect-timeout 10 --max-time 30 -o /dev/null "$url"; do + (( SECONDS < deadline )) || { echo "::error ::$PKG v$VERSION did not appear on pub.dev after publishing."; exit 1; } + echo " …not live yet; retrying in 10s" + sleep 10 + done + echo "✅ $PKG v$VERSION is live on pub.dev." + + - name: 📝 Extract CHANGELOG section + id: notes + shell: bash + env: + PKG: ${{ steps.parse.outputs.package }} + VERSION: ${{ steps.parse.outputs.version }} + run: | + set -euo pipefail + + # Resolve location from melos (dir name may differ from package name). + pkg_path="$(melos list --json 2>/dev/null | jq -r --arg n "$PKG" '.[] | select(.name==$n) | .location')" + rel_path="${pkg_path#"$GITHUB_WORKSPACE"/}" + changelog="$pkg_path/CHANGELOG.md" + notes_file="$RUNNER_TEMP/release_notes.md" + + # Match the version *token* ($2) so a dated heading still matches and + # "0.4.0" never matches "0.4.00". + if [[ -f "$changelog" ]]; then + awk -v ver="$VERSION" '/^## /{flag=($2==ver); next} flag' "$changelog" > "$notes_file" + fi + + # Publish is irreversible; never fail the release on a missing heading. + if [[ ! -s "$notes_file" ]]; then + echo "See [CHANGELOG](https://github.com/${GITHUB_REPOSITORY}/blob/${GITHUB_REF_NAME}/$rel_path/CHANGELOG.md)." > "$notes_file" + fi + + { + echo "" + echo "---" + echo "Published to pub.dev: https://pub.dev/packages/$PKG/versions/$VERSION" + } >> "$notes_file" + + echo "path=$notes_file" >> "$GITHUB_OUTPUT" + + - name: 🚀 Create GitHub Release + uses: softprops/action-gh-release@v3 + with: + tag_name: ${{ github.ref_name }} + name: ${{ steps.parse.outputs.package }} v${{ steps.parse.outputs.version }} + body_path: ${{ steps.notes.outputs.path }} + prerelease: ${{ steps.parse.outputs.prerelease }} + make_latest: false # independent versioning: no single "latest" release + token: ${{ secrets.BOT_GITHUB_API_TOKEN }} diff --git a/.github/workflows/release_tag.yml b/.github/workflows/release_tag.yml new file mode 100644 index 00000000..0a6f864a --- /dev/null +++ b/.github/workflows/release_tag.yml @@ -0,0 +1,45 @@ +name: release_tag + +on: + push: + branches: [main] + workflow_dispatch: # manual recovery, e.g. if a release commit's title was edited past the gate + +concurrency: + # false: never cancel a run mid tag-push (would drop a release). + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + # Manual dispatch, or a commit whose message starts `chore(<scope>): release` + # (no regex in GH expressions). A manual run tags whatever is unpublished. + if: "${{ github.event_name == 'workflow_dispatch' || (startsWith(github.event.head_commit.message, 'chore(') && contains(github.event.head_commit.message, '): release')) }}" + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: 📚 Checkout branch + # Bot PAT: GITHUB_TOKEN tag pushes don't trigger the publish workflow. + uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.BOT_GITHUB_API_TOKEN }} + + - name: 🐦 Install Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + + - name: 📦 Install Tools + run: flutter pub global activate melos + + - name: 🏷️ Tag unpublished packages + # Tag every unpublished package (state-derived, not from the commit msg) + # and push each in dependency order, one per event. An existing tag (e.g. + # a stale one on an old commit) fails `git tag` and is skipped with a warning. + shell: bash + run: | + set -euo pipefail + melos exec -c 1 --no-published --no-private --order-dependents -- \ + "if git tag \$MELOS_PACKAGE_NAME-v\$MELOS_PACKAGE_VERSION 2>/dev/null; then git push origin \$MELOS_PACKAGE_NAME-v\$MELOS_PACKAGE_VERSION; else echo \"::warning ::Skipped \$MELOS_PACKAGE_NAME-v\$MELOS_PACKAGE_VERSION: tag already exists (stale tag on an old commit?), not pushed.\"; fi" diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index db5a4754..d124e397 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -1373,6 +1373,78 @@ If a PR touches both `stream_core` and `stream_core_flutter`, update each packag `CHANGELOG.md` separately. Cross-linking between packages ("bumps stream_core to X.Y.Z") is handled by the release tooling — do not write these entries by hand. +### Releasing + +Publishing to [pub.dev](https://pub.dev) is automated. Packages are versioned +**independently**, each on its own tag `<package>-v<version>` (e.g. +`stream_core-v0.4.0`) — but a single release PR may bump **any number of +packages at once**. Each bumped package still gets its own tag and its own +publish run, so releasing all three together and releasing one on its own follow +the exact same steps. + +Cut every release from a `release/...` branch (e.g. `release/2026-07-30`). This +is required, not a convention: the changelog-placement check in +[`pr_title.yml`](.github/workflows/pr_title.yml) only allows a `## Upcoming` +heading to become `## X.Y.Z` on a `release/` branch. On that branch, for **each** +package you are releasing: + +- bump its `version` in `pubspec.yaml` +- promote its CHANGELOG `## Upcoming` heading to `## X.Y.Z` + +Title the PR `chore(repo): release packages` for a multi-package release +(generic, so it stays short), or `chore(<scope>): release <package> vX.Y.Z` +(scope `llc` / `ui` / `thumb`) for a single package. The tooling keys only on the `chore(...): release` prefix — tags +are derived from **package state**, not the title — so a title mentioning one +version while the PR bumps several still tags and publishes every bumped package. + +**Squash-merge the release PR.** `release_tag.yml`'s gate reads the *tip* +commit's message (`github.event.head_commit.message`), so a squash lands the +`chore(...): release` title as that commit. A **merge commit** would make the tip +`Merge pull request #… ` — the gate wouldn't fire and nothing would tag/publish, +silently. (This is why the tag job also has a `workflow_dispatch` escape hatch.) + +When the PR merges to `main`: + +1. [`release_tag.yml`](.github/workflows/release_tag.yml) tags every package + whose current version is not yet on pub.dev — `<package>-vX.Y.Z` — and pushes + the tags one at a time. +2. [`release_publish.yml`](.github/workflows/release_publish.yml) fires once per + pushed tag and publishes only that package (OIDC — no stored credentials), + then creates a GitHub Release whose body is the package's `## X.Y.Z` CHANGELOG + section. + +**Dependent order is handled automatically.** `stream_core_flutter` depends on +`stream_core`, and each package publishes in its own run, so releasing both +together could otherwise let the dependent reach pub.dev before its dependency +is indexed (which the server rejects with `Dependency … does not exist`). Before +publishing, `release_publish.yml`'s **⏳ Wait for in-workspace dependencies** step +polls pub.dev's per-version endpoint until every in-workspace dependency of the +tagged package is live, so publish never races ahead of a dependency. The +dependency's own run lands moments earlier (tags push in dependency order), so +the wait usually resolves within a poll or two — an already-live dependency +passes on the first check; a just-published one needs a retry or so while +pub.dev indexes it. If a dependency's publish genuinely *fails*, +the dependent's wait times out and reports it — re-run the failed dependency +(`workflow_dispatch` on its tag), then the dependent. Re-runs are safe: the +publish step skips if the version is already on pub.dev (checked against the +live per-version endpoint, not `melos --no-published`), so re-running a tag +publishes it only if it isn't already there. + +**Tagging is state-derived — mind two consequences.** `release_tag.yml` tags +*every* package whose current `pubspec.yaml` version isn't on pub.dev yet, not +only the ones this PR bumped. So: + +- **Keep version bumps to release PRs.** If a `version:` bump merges in an + ordinary feature PR, the next release will tag and publish it as a side effect. + Bump versions only on a `release/` branch. +- **Publish a brand-new package before releasing anything that depends on it.** + A new package's first publish needs pub.dev automated-publishing configured for + it; until then its automated publish fails. If that new package is also an + in-workspace dependency of an existing one (as `stream_core` is for + `stream_core_flutter`), releasing the dependent alongside it makes the + dependent's wait step poll for a version that never appears and time out after + 15 minutes. Land the new package on its own first (or set up its publishing and + let its run finish), then release the dependents. ## Where to look when you're stuck diff --git a/melos.yaml b/melos.yaml index 47541470..2f2404c5 100644 --- a/melos.yaml +++ b/melos.yaml @@ -113,12 +113,18 @@ scripts: - Note: you can also rely on your IDEs Dart Analysis / Issues window. lint:pub: - run: | - melos exec -c 5 --no-private --ignore="*example*" -- \ - flutter pub publish --dry-run + run: melos exec -c 1 --no-private --order-dependents -- "flutter pub publish -n" description: | - Run `pub publish --dry-run` in all packages. - - Note: you can also rely on your IDEs Dart Analysis / Issues window. + Dry-run `pub publish` for all non-private packages, in dependency order. + No `--no-published`: `-n` never publishes and skips collision checks, so it + stays useful even for already-released versions. Scope with MELOS_PACKAGES. + + release:pub: + run: melos exec -c 1 --no-published --no-private --order-dependents -- "flutter pub publish -f" + description: | + Publish the scoped package to pub.dev (OIDC in CI). Set MELOS_PACKAGES to + the target. Called by release_publish, which first checks the version + isn't already live and waits for in-workspace dependencies. generate:all: run: melos run generate:dart && melos run generate:flutter