From 8952e2bbb81d7cd32ef1f3f0f7f724cf3ae3f954 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 18 Aug 2026 14:48:57 +0200 Subject: [PATCH 1/6] ci(repo): add automated per-package pub.dev publishing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port stream-core-flutter's two-stage release automation (FLU-636) to feeds: merging a release PR tags every unpublished package, and the tag push publishes it to pub.dev over GitHub Actions OIDC. - release_tag.yml: on a `chore(...): release` commit on main, derives tags from package state (`-v`) and pushes each with the bot PAT. - release_publish.yml: on a `*-v*` tag push, validates the tag against the pubspec, publishes over OIDC, and cuts a GitHub Release from the CHANGELOG. - melos.yaml: add the `release:pub` script the publish workflow invokes. - .claude/skills/release-pr: skill that opens the release PR end to end. - AGENTS.md: document versioning, changelog curation, and the release flow. Also refresh the checked-in Flutter-generated files. `flutter pub get` rewrites them, so `melos bootstrap` left the tree dirty — which failed `pub publish --dry-run` on stream_feeds (example/analysis_options.yaml is inside the published package) and would trip the release pre-flight. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/release-pr/SKILL.md | 230 ++++++++++++++++++ .github/workflows/release_publish.yml | 206 ++++++++++++++++ .github/workflows/release_tag.yml | 45 ++++ .gitignore | 6 +- AGENTS.md | 45 ++++ docs/analysis_options.yaml | 8 + melos.yaml | 8 + .../example/analysis_options.yaml | 9 + sample_app/analysis_options.yaml | 7 + .../linux/flutter/generated_plugins.cmake | 1 + .../windows/flutter/generated_plugins.cmake | 1 + 11 files changed, 565 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/release-pr/SKILL.md create mode 100644 .github/workflows/release_publish.yml create mode 100644 .github/workflows/release_tag.yml diff --git a/.claude/skills/release-pr/SKILL.md b/.claude/skills/release-pr/SKILL.md new file mode 100644 index 00000000..47278d22 --- /dev/null +++ b/.claude/skills/release-pr/SKILL.md @@ -0,0 +1,230 @@ +--- +name: release-pr +description: > + Open a release PR for stream-feeds-flutter: bump the `stream_feeds` version, finalise its hand-curated + CHANGELOG (promote `## Upcoming` → `## X.Y.Z`), and open a PR from a `release/` branch. Merging the PR + triggers automatic tagging and pub.dev publishing. +disable-model-invocation: true +argument-hint: "[]" +arguments: [version] +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-feeds-flutter. Branch `release/stream_feeds-vX.Y.Z` → base `main` → title +`chore(llc): release stream_feeds vX.Y.Z`. + +**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 unpublished package +(`-vX.Y.Z`) and [`release_publish.yml`](../../../.github/workflows/release_publish.yml) publishes it +and cuts a GitHub Release. See the "Releasing" section of `AGENTS.md`. + +## Key facts for this repo + +- **One publishable package: `stream_feeds`.** `stream_feeds_test`, `docs`, `sample_app`, and + `packages/*/example` are private — `--no-private` is what keeps them out of tagging and publishing, so + never remove `publish_to: none` (or add a `version:` to `docs`) without understanding that. + Confirm the publishable set with `melos list --no-private`. +- **Independent per-package versioning** (`versioning: mode: independent` in `melos.yaml`). The release tag + is `stream_feeds-vX.Y.Z`, not a plain `vX.Y.Z`. Older plain `v0.x` tags are history and no longer match + the publish trigger. +- **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 a convention here**, not an enforced check (unlike stream-core-flutter, this repo's + `pr_title.yml` has no changelog-placement job). Use it anyway so release PRs are recognisable. + +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 +``` + +Today: `llc` → `packages/stream_feeds`. (`repo` and `samples` are valid PR scopes but map to no package.) + +## Inputs + +1. **Version.** If given as an arg (e.g. `/release-pr 0.5.2`), use it; strip any leading `v`. Otherwise + **propose and confirm**: derive a version per [Choosing the version](#choosing-the-version) and let the + user confirm or override. +2. **Base branch** is always `main`. + +A release is warranted when `packages/stream_feeds/CHANGELOG.md` has a non-empty `## Upcoming` section. If it +doesn't, say so and stop — there is nothing to release. + +## Choosing the version + +Two steps: classify the release from its CHANGELOG, then map that onto the current version. + +**1. Classify.** Read the curated `## Upcoming` section — the same bullets that become the release notes, so +they are the authority on what the release contains: + +```bash +sed -n '/^## Upcoming/,/^## [0-9]/p' packages/stream_feeds/CHANGELOG.md +``` + +This repo's sub-headings are free-form and descriptive (`### New fields`, `### WebSocket events`, +`### Deprecated — renamed types`, `### 🛑 Breaking`, …), so a keyword grep is a **signal, not a verdict**: + +```bash +sed -n '/^## Upcoming/,/^## [0-9]/p' packages/stream_feeds/CHANGELOG.md | grep -in 'breaking\|removed\|renamed' +``` + +**Actually read the section** and classify it yourself: + +- **breaking** — an existing API is removed, renamed without a compatible alias, changes signature, or + changes behaviour in a way that breaks callers. +- **compatible, adds API** — new fields, new methods, new events, new optional parameters. +- **no public API change** — internal fixes, docs, dependency bumps. + +**Deprecations are not breaking** — a deprecated API still works, so a release that only deprecates (e.g. +renamed types shipped with backwards-compatible `typedef` aliases) is compatible. A rename **without** an +alias is breaking. + +**2. Map onto the current version.** The same release is a different bump depending on where the package +sits, per the Dart community convention in +[Package versioning](https://dart.dev/tools/pub/versioning#semantic-versions): + +> Although semantic versioning doesn't promise any compatibility between versions prior to `1.0.0`, the Dart +> community convention is to treat those versions semantically as well. The interpretation of each number is +> just shifted down one slot: going from `0.1.2` to `0.2.0` indicates a breaking change, going to `0.1.3` +> indicates a new feature, and going to `0.1.2+1` indicates a change that doesn't affect the public API. For +> simplicity's sake, avoid using `+` after the version reaches `1.0.0`. + +Read the current version first — `grep '^version:' packages/stream_feeds/pubspec.yaml` — and pick the column +from it rather than assuming either regime: + +| Release | at/above `1.0.0` | below `1.0.0` | +| --- | --- | --- | +| breaking | major | minor | +| compatible, adds API | minor | patch | +| no public API change | patch | build (`+1`) | + +`stream_feeds` is currently below `1.0.0`, so the right-hand column applies: a breaking release is a **minor** +bump and a feature release is a **patch** bump. + +The shift matters because a caret constraint stops at the leading significant digit: `^1.4.1` means +`>=1.4.1 <2.0.0` (major breaks), while `^0.5.1` means `>=0.5.1 <0.6.0` (minor breaks). Whichever slot that is, +bumping it strands every consumer on the old caret until they hand-edit their pubspec — so bump it only for a +genuinely breaking release. + +State the proposed version, what in the `## Upcoming` section drove the classification, and which column you +used; then ask the user to confirm — **they still decide**. If they supply a version that disagrees with the +derivation, say so once, then use theirs. + +A behavior change that is source-compatible but alters observable output (changed defaults, changed state +semantics, changed event handling) is breaking for consumers even though it compiles — classify it as +breaking, and file it under a `### 🛑 Breaking` heading when writing the entry. + +> There is no undo: pub.dev cannot delete a published version. Get the bump right before merge. + +## 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 release/stream_feeds-vX.Y.Z +``` + +### 2. Bump the version + +- Set `version: ` in `packages/stream_feeds/pubspec.yaml`. +- Bump the `stream_feeds:` entry in `melos.yaml`'s `command.bootstrap.dependencies` block + (`grep -nE '^\s+stream_feeds:' melos.yaml`) **only if** the existing caret no longer allows the new version + (e.g. `0.5.1 → 0.6.0` under `stream_feeds: ^0.5.1`). A bump the caret already covers (`0.5.1 → 0.5.2`) needs + no change. This block is what `docs`, `sample_app`, and the example app resolve against. + +Then propagate constraints: + +```bash +melos bootstrap +``` + +Do **not** run `melos version`. + +### 3. Finalise the CHANGELOG + +In `packages/stream_feeds/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 stay untouched. + +The `## ` section must be non-empty (pana fails on an empty or missing one). + +### 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 "chore(llc): release stream_feeds vX.Y.Z" +git push -u origin release/stream_feeds-vX.Y.Z +``` + +Single commit. **The title prefix is load-bearing** — `release_tag.yml` gates on `chore(` … `): release`. +Tagging derives from package state, not this title, so a typo in the package name or version 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 section (the same content that becomes the 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 release/stream_feeds-vX.Y.Z \ + --title "chore(llc): release stream_feeds vX.Y.Z" --body-file +``` + +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(llc): 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 `stream_feeds-vX.Y.Z` and pushes it with the bot PAT; `release_publish.yml` fires on +that tag push, publishes to pub.dev over OIDC, and creates a GitHub Release from the CHANGELOG section. Both +are idempotent — a re-run against a version already live on pub.dev is a clean no-op. + +## Don't + +- **Never bump the breaking slot without a breaking change** in `## Upcoming` — below `1.0.0` that slot is the + **minor**, and bumping it strands every consumer on the old caret. See + [Choosing the version](#choosing-the-version). +- **Never run `melos version`** — it clobbers the hand-curated CHANGELOG. +- **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 either.) +- **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: + # -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" + + # -v, 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 '-v'." + 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(): 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/.gitignore b/.gitignore index b349bd38..f37fa48e 100644 --- a/.gitignore +++ b/.gitignore @@ -141,4 +141,8 @@ env.g.dart # Non-CI golden files and failures **/test/**/goldens/**/*.* **/test/**/failures/**/*.* -!**/test/**/goldens/ci/*.* \ No newline at end of file +!**/test/**/goldens/ci/*.* + +# Claude Code local state. `.claude/skills/` is checked in and shared. +/.claude/worktrees/ +/.claude/settings.local.json diff --git a/AGENTS.md b/AGENTS.md index 5b719a00..d843d32a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -426,6 +426,51 @@ melos run lint:all - SDK uses semantic versioning - Version managed in `packages/stream_feeds/pubspec.yaml` - Versioning mode: independent (per-package) +- Below `1.0.0` the Dart convention shifts every slot down one: a breaking release is a **minor** bump, a + feature release is a **patch** bump, and a change with no public API impact is a build (`+1`) bump. See + [Package versioning](https://dart.dev/tools/pub/versioning#semantic-versions). + +### Changelog + +`packages/stream_feeds/CHANGELOG.md` is **hand-curated**. New entries go under the top `## Upcoming` heading, +never into a section for an already-published version. Releasing *promotes* that heading to `## X.Y.Z` — it +never rewrites the bullets. Do not run `melos version`; it regenerates entries from commit messages and +clobbers the curated ones. + +### Releasing + +Publishing to pub.dev is automated and authenticates over GitHub Actions OIDC — no pub.dev credentials are +stored anywhere. `stream_feeds` is the only publishable package; everything else in the workspace is private +(`publish_to: none`, or no `version:`) and is excluded by `--no-private`. + +Cut the release from a `release/` branch (e.g. `release/stream_feeds-v0.5.2`): + +1. Bump `version:` in `packages/stream_feeds/pubspec.yaml`, and the `stream_feeds:` entry in `melos.yaml`'s + `command.bootstrap.dependencies` block if the existing caret no longer covers the new version. +2. Promote `## Upcoming` → `## X.Y.Z` in the CHANGELOG. The section must be non-empty (pana fails otherwise). +3. `melos bootstrap && melos run analyze && melos run lint:pub`. +4. Open a PR titled `chore(): release stream_feeds vX.Y.Z` (scope `llc`). + +**Squash-merge the release PR.** [`release_tag.yml`](.github/workflows/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. The tooling keys only on the +`chore(...): release` prefix — tags are derived from package state, not parsed from the title, so a typo in +the package name or version cannot mis-tag. + +After merge: + +1. [`release_tag.yml`](.github/workflows/release_tag.yml) tags every unpublished package as + `-vX.Y.Z` (e.g. `stream_feeds-v0.5.2`) and pushes each with the bot PAT. Note the per-package + tag format — older plain `v0.x` tags are history and no longer match the publish trigger. +2. [`release_publish.yml`](.github/workflows/release_publish.yml) fires on that tag push, verifies the tag + version matches the pubspec, publishes over OIDC, then creates a GitHub Release whose body is the + `## X.Y.Z` CHANGELOG section. + +Both workflows are idempotent: re-running against a version already live on pub.dev is a clean no-op, and +`workflow_dispatch` on the tag ref is a safe recovery path. Never tag, publish (`melos run release:pub`), or +create a GitHub Release by hand — CI owns all three. + +Agents: `.claude/skills/release-pr/SKILL.md` walks through this end to end. ## Getting Help diff --git a/docs/analysis_options.yaml b/docs/analysis_options.yaml index 038e843c..088f1501 100644 --- a/docs/analysis_options.yaml +++ b/docs/analysis_options.yaml @@ -3,6 +3,14 @@ include: ../analysis_options.yaml analyzer: errors: unused_local_variable: ignore + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** linter: rules: diff --git a/melos.yaml b/melos.yaml index 042faa0d..ea0cca23 100644 --- a/melos.yaml +++ b/melos.yaml @@ -133,6 +133,14 @@ scripts: description: | Run `pub publish --dry-run` in all packages. - Note: you can also rely on your IDEs Dart Analysis / Issues window. + - Note: scope it to a single package 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 diff --git a/packages/stream_feeds/example/analysis_options.yaml b/packages/stream_feeds/example/analysis_options.yaml index f9b30346..743e05ad 100644 --- a/packages/stream_feeds/example/analysis_options.yaml +++ b/packages/stream_feeds/example/analysis_options.yaml @@ -1 +1,10 @@ +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:flutter_lints/flutter.yaml diff --git a/sample_app/analysis_options.yaml b/sample_app/analysis_options.yaml index f031fcbe..7ccf8e2a 100644 --- a/sample_app/analysis_options.yaml +++ b/sample_app/analysis_options.yaml @@ -6,6 +6,13 @@ analyzer: # exclude all the generated files - lib/**/*.*.dart - lib/firebase_options.dart + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** linter: rules: diff --git a/sample_app/linux/flutter/generated_plugins.cmake b/sample_app/linux/flutter/generated_plugins.cmake index 2e1de87a..be1ee3e5 100644 --- a/sample_app/linux/flutter/generated_plugins.cmake +++ b/sample_app/linux/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/sample_app/windows/flutter/generated_plugins.cmake b/sample_app/windows/flutter/generated_plugins.cmake index 664a7422..04f65d2b 100644 --- a/sample_app/windows/flutter/generated_plugins.cmake +++ b/sample_app/windows/flutter/generated_plugins.cmake @@ -8,6 +8,7 @@ list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST flutter_local_notifications_windows + jni ) set(PLUGIN_BUNDLED_LIBRARIES) From 28c093c10aab926353ad83499eef02bcc0fbbd88 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 18 Aug 2026 14:59:02 +0200 Subject: [PATCH 2/6] =?UTF-8?q?ci(repo):=20match=20chat's=20release=20desi?= =?UTF-8?q?gn=20=E2=80=94=20plain=20tags,=20generated=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switches from Core's per-package tag scheme to chat/video's, since feeds releases behind a single version. - Tags are plain `vX.Y.Z` again, continuing the repo's existing tag history, and are parsed from the release commit message. pub.dev keeps the `v{{version}}` tag pattern. - release_publish.yml is chat's: dry run, publish, GitHub Release with generated notes. `make_latest` stays at its default (true) — every release is the latest one here, unlike Core's independently-versioned packages. - Drops the per-package parse, version guard, dependency wait, and CHANGELOG body extraction — all of which existed to disambiguate between Core's three packages. Two deliberate deviations from chat: the tag job's gate is scope-agnostic (`chore(` + `): release`) because feeds releases land as `chore(llc): release` as often as `chore(repo):`, and the commit message is read via an env var rather than inline `${{ }}` — untrusted input, and actionlint flags the inline form. Skill reworked onto chat's, which fixes a real ordering bug: `lint:pub` shells out to `pub publish --dry-run`, which fails on a dirty tree, so it has to run *after* the release commit, not before. Also reverts the .gitignore additions. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/release-pr/SKILL.md | 239 ++++++++++++++++---------- .github/workflows/release_publish.yml | 187 +++----------------- .github/workflows/release_tag.yml | 62 ++++--- .gitignore | 6 +- AGENTS.md | 34 ++-- melos.yaml | 7 +- 6 files changed, 234 insertions(+), 301 deletions(-) diff --git a/.claude/skills/release-pr/SKILL.md b/.claude/skills/release-pr/SKILL.md index 47278d22..89c9effe 100644 --- a/.claude/skills/release-pr/SKILL.md +++ b/.claude/skills/release-pr/SKILL.md @@ -1,11 +1,10 @@ --- name: release-pr description: > - Open a release PR for stream-feeds-flutter: bump the `stream_feeds` version, finalise its hand-curated - CHANGELOG (promote `## Upcoming` → `## X.Y.Z`), and open a PR from a `release/` branch. Merging the PR - triggers automatic tagging and pub.dev publishing. + Open a release PR for stream-feeds-flutter: bump the version in melos.yaml and the package pubspec, finalise + the CHANGELOG, and open a PR against main with auto-generated release notes. disable-model-invocation: true -argument-hint: "[]" +argument-hint: "[version]" arguments: [version] allowed-tools: - Bash(git *) @@ -21,45 +20,34 @@ allowed-tools: # release-pr -Opens a release PR for stream-feeds-flutter. Branch `release/stream_feeds-vX.Y.Z` → base `main` → title -`chore(llc): release stream_feeds vX.Y.Z`. +Opens a release PR for stream-feeds-flutter. Branch `release/v` → base `main` → title +`chore(llc): release v`. **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 unpublished package -(`-vX.Y.Z`) and [`release_publish.yml`](../../../.github/workflows/release_publish.yml) publishes it -and cuts a GitHub Release. See the "Releasing" section of `AGENTS.md`. +[`release_tag.yml`](../../../.github/workflows/release_tag.yml) extracts `vX.Y.Z` from the commit message and +pushes the tag; [`release_publish.yml`](../../../.github/workflows/release_publish.yml) publishes to pub.dev +over OIDC and creates the GitHub Release. See the "Releasing" section of `AGENTS.md`. + +If `$version` is provided (e.g. `/release-pr 0.5.2`), use it. Strip any leading `v`. Otherwise derive and +confirm per [Choosing the version](#choosing-the-version). ## Key facts for this repo - **One publishable package: `stream_feeds`.** `stream_feeds_test`, `docs`, `sample_app`, and - `packages/*/example` are private — `--no-private` is what keeps them out of tagging and publishing, so - never remove `publish_to: none` (or add a `version:` to `docs`) without understanding that. - Confirm the publishable set with `melos list --no-private`. -- **Independent per-package versioning** (`versioning: mode: independent` in `melos.yaml`). The release tag - is `stream_feeds-vX.Y.Z`, not a plain `vX.Y.Z`. Older plain `v0.x` tags are history and no longer match - the publish trigger. -- **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 a convention here**, not an enforced check (unlike stream-core-flutter, this repo's - `pr_title.yml` has no changelog-placement job). Use it anyway so release PRs are recognisable. - -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 -``` - -Today: `llc` → `packages/stream_feeds`. (`repo` and `samples` are valid PR scopes but map to no package.) + `packages/*/example` are private (`publish_to: none`, or no `version:`); `--no-private` is what keeps them + out of publishing. Confirm the publishable set with `melos list --no-private`. +- **Tags are plain `vX.Y.Z`**, continuing the repo's existing tag history — not per-package tags. +- **CHANGELOG is hand-curated.** Never run `melos version` — it regenerates entries from commit messages and + clobbers the curated `## Upcoming` bullets. Releasing means *promoting* `## Upcoming` to `## X.Y.Z`. +- **`release/` branch is a convention** here, not an enforced check. Use it anyway so release PRs are + recognisable. ## Inputs -1. **Version.** If given as an arg (e.g. `/release-pr 0.5.2`), use it; strip any leading `v`. Otherwise - **propose and confirm**: derive a version per [Choosing the version](#choosing-the-version) and let the - user confirm or override. -2. **Base branch** is always `main`. +1. **Version** (`X.Y.Z` or `X.Y.Z-suffix`). Use `$version` if supplied; otherwise derive it below and confirm + with the user. Don't silently infer. +2. **Previous tag** for the release-notes diff: `gh release list --limit 10` — the most recent tag of the same + train (stable = no hyphen in the tag; pre-release = matches the same suffix prefix). A release is warranted when `packages/stream_feeds/CHANGELOG.md` has a non-empty `## Upcoming` section. If it doesn't, say so and stop — there is nothing to release. @@ -76,7 +64,7 @@ sed -n '/^## Upcoming/,/^## [0-9]/p' packages/stream_feeds/CHANGELOG.md ``` This repo's sub-headings are free-form and descriptive (`### New fields`, `### WebSocket events`, -`### Deprecated — renamed types`, `### 🛑 Breaking`, …), so a keyword grep is a **signal, not a verdict**: +`### Deprecated — renamed types`, …), so a keyword grep is a **signal, not a verdict**: ```bash sed -n '/^## Upcoming/,/^## [0-9]/p' packages/stream_feeds/CHANGELOG.md | grep -in 'breaking\|removed\|renamed' @@ -84,8 +72,8 @@ sed -n '/^## Upcoming/,/^## [0-9]/p' packages/stream_feeds/CHANGELOG.md | grep - **Actually read the section** and classify it yourself: -- **breaking** — an existing API is removed, renamed without a compatible alias, changes signature, or - changes behaviour in a way that breaks callers. +- **breaking** — an existing API is removed, renamed without a compatible alias, changes signature, or changes + behaviour in a way that breaks callers. - **compatible, adds API** — new fields, new methods, new events, new optional parameters. - **no public API change** — internal fixes, docs, dependency bumps. @@ -93,15 +81,14 @@ sed -n '/^## Upcoming/,/^## [0-9]/p' packages/stream_feeds/CHANGELOG.md | grep - renamed types shipped with backwards-compatible `typedef` aliases) is compatible. A rename **without** an alias is breaking. -**2. Map onto the current version.** The same release is a different bump depending on where the package -sits, per the Dart community convention in +**2. Map onto the current version.** The same release is a different bump depending on where the package sits, +per the Dart community convention in [Package versioning](https://dart.dev/tools/pub/versioning#semantic-versions): > Although semantic versioning doesn't promise any compatibility between versions prior to `1.0.0`, the Dart > community convention is to treat those versions semantically as well. The interpretation of each number is > just shifted down one slot: going from `0.1.2` to `0.2.0` indicates a breaking change, going to `0.1.3` -> indicates a new feature, and going to `0.1.2+1` indicates a change that doesn't affect the public API. For -> simplicity's sake, avoid using `+` after the version reaches `1.0.0`. +> indicates a new feature, and going to `0.1.2+1` indicates a change that doesn't affect the public API. Read the current version first — `grep '^version:' packages/stream_feeds/pubspec.yaml` — and pick the column from it rather than assuming either regime: @@ -116,104 +103,171 @@ from it rather than assuming either regime: bump and a feature release is a **patch** bump. The shift matters because a caret constraint stops at the leading significant digit: `^1.4.1` means -`>=1.4.1 <2.0.0` (major breaks), while `^0.5.1` means `>=0.5.1 <0.6.0` (minor breaks). Whichever slot that is, -bumping it strands every consumer on the old caret until they hand-edit their pubspec — so bump it only for a -genuinely breaking release. +`>=1.4.1 <2.0.0`, while `^0.5.1` means `>=0.5.1 <0.6.0`. Bumping that slot strands every consumer on the old +caret until they hand-edit their pubspec — so bump it only for a genuinely breaking release. -State the proposed version, what in the `## Upcoming` section drove the classification, and which column you -used; then ask the user to confirm — **they still decide**. If they supply a version that disagrees with the -derivation, say so once, then use theirs. - -A behavior change that is source-compatible but alters observable output (changed defaults, changed state -semantics, changed event handling) is breaking for consumers even though it compiles — classify it as -breaking, and file it under a `### 🛑 Breaking` heading when writing the entry. +State the proposed version, what in `## Upcoming` drove the classification, and which column you used; then +ask the user to confirm — **they still decide**. If they supply a version that disagrees with the derivation, +say so once, then use theirs. > There is no undo: pub.dev cannot delete a published version. Get the bump right before merge. ## 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). +Run these checks. **If any fail, stop, surface the failing check to the user, and do not try to auto-fix** (no +stashing uncommitted work, no force-pulling, 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. +- `git status --short -uno` clean after `git checkout main` + `git pull --ff-only`. +- `which melos` and `gh auth status` succeed. +- `gh pr list --head release/v --state all --json number` returns `[]`. - 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 `[]`. +- **No `git:` or `path:` entry under `stream_feeds`'s `dependencies`.** pub.dev rejects both, so + `release_publish.yml` would fail at publish time after the tag is already pushed: + + ```bash + grep -n -B2 -A4 -E '^\s+(git|path):' packages/stream_feeds/pubspec.yaml melos.yaml + ``` + + A `path:`/`git:` entry under **`dev_dependencies`** is fine — pub ignores those when publishing, and + `stream_feeds` legitimately dev-depends on `../stream_feeds_test`. Only a real `dependencies` entry blocks. + In particular check `stream_core`: if it is pinned to a git ref for development, it must be back on a + published version constraint before release. Surface it and stop — don't pick the constraint yourself. ## Steps ### 1. Branch off main +Pre-flight already left you on `main` with latest. Just create the release branch: + ```bash -git checkout -b release/stream_feeds-vX.Y.Z +git checkout -b release/v ``` ### 2. Bump the version -- Set `version: ` in `packages/stream_feeds/pubspec.yaml`. -- Bump the `stream_feeds:` entry in `melos.yaml`'s `command.bootstrap.dependencies` block - (`grep -nE '^\s+stream_feeds:' melos.yaml`) **only if** the existing caret no longer allows the new version - (e.g. `0.5.1 → 0.6.0` under `stream_feeds: ^0.5.1`). A bump the caret already covers (`0.5.1 → 0.5.2`) needs - no change. This block is what `docs`, `sample_app`, and the example app resolve against. +**Edit two files by hand:** -Then propagate constraints: +- `packages/stream_feeds/pubspec.yaml` — set `version: `. +- `melos.yaml` — in the `command.bootstrap.dependencies` block, set `stream_feeds: ^`. Locate with + `grep -n '^\s\+stream_feeds:' melos.yaml`. This block is what `docs`, `sample_app`, and the example app + resolve against. + +Do **not** touch the `version:` field in `packages/*/example/pubspec.yaml` or `sample_app/pubspec.yaml` — +those are app versions, and their `stream_feeds` dependency is synced by bootstrap. + +**Then run:** ```bash melos bootstrap ``` -Do **not** run `melos version`. +This propagates the `melos.yaml` deps block into every workspace pubspec. Do **not** run `melos version`. + +Verify the diff shape against the previous release PR: + +```bash +gh pr list --search "release in:title" --state merged --limit 5 --json number,title +git diff --stat +gh pr diff --name-only # for comparison +``` ### 3. Finalise the CHANGELOG -In `packages/stream_feeds/CHANGELOG.md`, rename the top `## Upcoming` heading to `## `. Keep the +In `packages/stream_feeds/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 stay untouched. -The `## ` section must be non-empty (pana fails on an empty or missing one). +The `## ` section must be non-empty — pana fails on an empty or missing one. -### 4. Sanity-check +### 4. Analyze, then commit ```bash melos run analyze +``` + +If it fails, surface to the user and stop. + +```bash +git status --short # nothing untracked should be release material +git add -u # tracked modifications only +git commit -m "chore(llc): release v" +``` + +`git add -u`, not `-A`: pre-flight's `git status --short -uno` ignores untracked files, so `-A` would sweep +local artifacts into the release commit. If a release ever needs a genuinely new tracked file, add it by path. + +Single commit. **The message format is load-bearing** — `release_tag.yml` parses `vX.Y.Z` out of it, and gates +on the tip commit of `main`, so the PR must be **squash-merged**. A merge commit would leave +`Merge pull request #…` at the tip and the tag job would silently never fire. + +`melos run lint:pub` is deliberately **not** in this step: it shells out to `pub publish --dry-run`, which +fails any dirty tree with "N checked-in files are modified in git". It can only pass once the release commit +exists — hence step 5. + +### 5. Verify publishability, then push + +```bash melos run lint:pub ``` -If either fails, surface it and stop. +This is the real publish gate. Read failures carefully — pub reports two severities and only one blocks: + +- **"Package validation found the following error"** — blocks. `release_publish.yml` runs `pub publish -f`, + and `-f` does **not** bypass errors. Must be fixed before merge. +- **"potential issue" / "Package has N warnings"** — `-f` publishes through these. Worth fixing, not blocking. + +A common error is a `lib/` or `test/` file importing a package absent from `stream_feeds`'s own +`dependencies` / `dev_dependencies`; it resolves locally through a transitive dep and only `pub publish` +catches it. Fix at the import or by declaring the dep, and tell the user the release PR now carries a source +change. + +If it fails, surface to the user and stop — don't push. + +```bash +git push -u origin release/v +``` + +### 6. Generate the PR body -### 5. Commit and push +`release_publish.yml` creates the GitHub Release with `generate_release_notes: true`, so the PR body should be +**exactly** what GitHub's release UI produces — no template wrapper, no extra description, no CLA checkboxes. +The "New Contributors" block GitHub auto-appends stays in. ```bash -git add -A -git commit -m "chore(llc): release stream_feeds vX.Y.Z" -git push -u origin release/stream_feeds-vX.Y.Z +gh api repos/GetStream/stream-feeds-flutter/releases/generate-notes \ + -f tag_name=v \ + -f previous_tag_name=v \ + -f target_commitish=main \ + --jq .body > /tmp/release-notes.md ``` -Single commit. **The title prefix is load-bearing** — `release_tag.yml` gates on `chore(` … `): release`. -Tagging derives from package state, not this title, so a typo in the package name or version can't mis-tag — -but keep the prefix intact or the tag job won't fire. +- `tag_name`: the tag we'll create (need not exist yet). +- `previous_tag_name`: the most recent tag of the same train. +- `target_commitish`: `main`, not the release branch — the notes should cover every commit between + `previous_tag_name` and where the tag will land after merge. -### 6. Open the PR +Read the file once to skim. If a PR title looks wrong, fix it on the originating PR upstream and re-run the +API call; don't hand-edit `/tmp/release-notes.md`. -Build the body from the promoted CHANGELOG section (the same content that becomes the GitHub Release). Do -**not** use `gh api .../generate-notes` — this repo deliberately does not use GitHub's generated notes. +### 7. Open the PR ```bash -gh pr create --base main --head release/stream_feeds-vX.Y.Z \ - --title "chore(llc): release stream_feeds vX.Y.Z" --body-file +gh pr create \ + --base main \ + --head release/v \ + --title "chore(llc): release v" \ + --body-file /tmp/release-notes.md ``` 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(llc): release …` title as that commit. A merge commit would make the tip `Merge pull request #…` -and the release would silently not run. +**Tell the user to squash-merge it** (see step 4 for why). ## After merge (FYI) -`release_tag.yml` tags `stream_feeds-vX.Y.Z` and pushes it with the bot PAT; `release_publish.yml` fires on -that tag push, publishes to pub.dev over OIDC, and creates a GitHub Release from the CHANGELOG section. Both -are idempotent — a re-run against a version already live on pub.dev is a clean no-op. +`release_tag.yml` extracts `vX.Y.Z` from the tip commit and pushes the tag with the bot PAT. +`release_publish.yml` fires on that tag push, runs `melos run lint:pub` then `melos run release:pub` (OIDC), +and creates the GitHub Release with generated notes. `--no-published` makes a re-run against a version already +live on pub.dev a clean no-op. ## Don't @@ -221,10 +275,9 @@ are idempotent — a re-run against a version already live on pub.dev is a clean **minor**, and bumping it strands every consumer on the old caret. See [Choosing the version](#choosing-the-version). - **Never run `melos version`** — it clobbers the hand-curated CHANGELOG. -- **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 either.) -- **Never create a GitHub release** (`gh release create`) — `release_publish.yml` creates it after the tag is - pushed. +- **Never create a GitHub release** (`gh release create`, `POST /repos/.../releases`). Step 6 uses + `generate-notes`, which is read-only; the release itself is created by `release_publish.yml`. +- **Never push a tag** — `release_tag.yml` does it on merge. +- **Never run `melos run release:pub`** — that's the publish step, triggered by the workflow on tag push. Even + if the user asks, refuse: running it locally publishes from an unreviewed working tree. - **Never merge the PR.** Return the URL and stop. diff --git a/.github/workflows/release_publish.yml b/.github/workflows/release_publish.yml index d7a66a40..d38a5b9a 100644 --- a/.github/workflows/release_publish.yml +++ b/.github/workflows/release_publish.yml @@ -3,80 +3,33 @@ name: release_publish on: push: tags: - # -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 + - 'v[0-9]+.[0-9]+.[0-9]+' # tag-pattern for regular releases + - 'v[0-9]+.[0-9]+.[0-9]+-*' # tag-pattern for pre-releases + workflow_dispatch: # Allow manual triggering of the workflow 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 + cancel-in-progress: true jobs: release: permissions: - contents: write # create the GitHub Release - id-token: write # OIDC auth to pub.dev + contents: write # Required to creating release + id-token: write # Required for authentication using OIDC 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 + uses: actions/checkout@v7 with: fetch-depth: 0 + token: ${{ secrets.BOT_GITHUB_API_TOKEN }} - - name: 🏷️ Parse package and version from tag - id: parse - shell: bash - run: | - set -euo pipefail - - ref="${GITHUB_REF#refs/tags/}" - echo "📦 Tag: $ref" - - # -v, 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 '-v'." - 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" - + # Set up the Dart SDK and provision the OIDC token used for publishing. - 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 @@ -85,122 +38,34 @@ jobs: 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 + - name: 📢 Release to pub.dev + run: 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 + - name: 🏷️ Extract Version Info + id: extract_version 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 + tag_name="${{ github.ref_name }}" + echo "📦 Tag: $tag_name" - { - echo "" - echo "---" - echo "Published to pub.dev: https://pub.dev/packages/$PKG/versions/$VERSION" - } >> "$notes_file" + # Check if this is a pre-release (contains hyphen after version) + is_prerelease=$([[ $tag_name == *-* ]] && echo true || echo false) + echo "ℹ️ Pre-release: $is_prerelease" - echo "path=$notes_file" >> "$GITHUB_OUTPUT" + echo "tag=$tag_name" >> "$GITHUB_OUTPUT" + echo "prerelease=$is_prerelease" >> "$GITHUB_OUTPUT" + # `make_latest` is left at its default (true): the repo releases in + # lockstep behind a single version, so every release is the latest one. + # (Pre-releases are never marked latest by GitHub regardless.) - 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 + generate_release_notes: true + tag_name: ${{ steps.extract_version.outputs.tag }} + prerelease: ${{ steps.extract_version.outputs.prerelease }} token: ${{ secrets.BOT_GITHUB_API_TOKEN }} diff --git a/.github/workflows/release_tag.yml b/.github/workflows/release_tag.yml index 0a6f864a..17b022eb 100644 --- a/.github/workflows/release_tag.yml +++ b/.github/workflows/release_tag.yml @@ -3,43 +3,65 @@ 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 + cancel-in-progress: true jobs: release: - # Manual dispatch, or a commit whose message starts `chore(): 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')) }}" + # Only run this job for commits that indicate a release. Scope-agnostic: + # release commits here are `chore(llc): release vX.Y.Z` as often as + # `chore(repo): …`, and GH expressions have no regex. + if: "${{ startsWith(github.event.head_commit.message, 'chore(') && contains(github.event.head_commit.message, '): release') }}" runs-on: ubuntu-latest permissions: - contents: write + contents: write # Required to create and push tags + steps: - name: 📚 Checkout branch # Bot PAT: GITHUB_TOKEN tag pushes don't trigger the publish workflow. - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: fetch-depth: 0 token: ${{ secrets.BOT_GITHUB_API_TOKEN }} - - name: 🐦 Install Flutter - uses: subosito/flutter-action@v2 - with: - channel: stable + - name: 🏷️ Extract Version Tag + id: extract_tag + shell: bash + # Via env, not inline `${{ }}`: a commit message is untrusted input and + # would otherwise be interpolated straight into the script. + env: + COMMIT_MSG: ${{ github.event.head_commit.message }} + run: | + set -euo pipefail - - name: 📦 Install Tools - run: flutter pub global activate melos + commit_msg="$COMMIT_MSG" + echo "📦 Commit message: $commit_msg" - - 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. + # Match vX.Y.Z or vX.Y.Z-suffix (case-insensitive) + version_regex='[vV][0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.\-]+)?' + + if [[ "$commit_msg" =~ $version_regex ]]; then + version="${BASH_REMATCH[0]}" + echo "✅ Found version tag: $version" + echo "tag=$version" >> "$GITHUB_OUTPUT" + else + echo "::error ::❌ No SemVer tag found in commit message." + echo "::error ::Expected something like: 'chore(llc): release v1.2.3[-beta]'" + exit 1 + fi + + - name: 🚀 Create and Push Tag shell: bash + env: + TAG: ${{ steps.extract_tag.outputs.tag }} 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" + + git config user.name "Stream SDK Bot" + git config user.email "60655709+Stream-SDK-Bot@users.noreply.github.com" + + echo "Creating and pushing tag: $TAG" + git tag "$TAG" + git push origin "$TAG" diff --git a/.gitignore b/.gitignore index f37fa48e..b349bd38 100644 --- a/.gitignore +++ b/.gitignore @@ -141,8 +141,4 @@ env.g.dart # Non-CI golden files and failures **/test/**/goldens/**/*.* **/test/**/failures/**/*.* -!**/test/**/goldens/ci/*.* - -# Claude Code local state. `.claude/skills/` is checked in and shared. -/.claude/worktrees/ -/.claude/settings.local.json +!**/test/**/goldens/ci/*.* \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index d843d32a..3c32ca58 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -443,32 +443,30 @@ Publishing to pub.dev is automated and authenticates over GitHub Actions OIDC stored anywhere. `stream_feeds` is the only publishable package; everything else in the workspace is private (`publish_to: none`, or no `version:`) and is excluded by `--no-private`. -Cut the release from a `release/` branch (e.g. `release/stream_feeds-v0.5.2`): +Cut the release from a `release/` branch (e.g. `release/v0.5.2`): -1. Bump `version:` in `packages/stream_feeds/pubspec.yaml`, and the `stream_feeds:` entry in `melos.yaml`'s - `command.bootstrap.dependencies` block if the existing caret no longer covers the new version. +1. Bump `version:` in `packages/stream_feeds/pubspec.yaml` and the `stream_feeds:` entry in `melos.yaml`'s + `command.bootstrap.dependencies` block, then `melos bootstrap` to propagate. 2. Promote `## Upcoming` → `## X.Y.Z` in the CHANGELOG. The section must be non-empty (pana fails otherwise). -3. `melos bootstrap && melos run analyze && melos run lint:pub`. -4. Open a PR titled `chore(): release stream_feeds vX.Y.Z` (scope `llc`). +3. `melos run analyze`, commit, then `melos run lint:pub` — the dry run shells out to `pub publish`, which + fails on a dirty tree, so it can only pass once the release commit exists. +4. Open a PR titled `chore(): release vX.Y.Z` (scope `llc`), body = GitHub's generated release notes. **Squash-merge the release PR.** [`release_tag.yml`](.github/workflows/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. The tooling keys only on the -`chore(...): release` prefix — tags are derived from package state, not parsed from the title, so a typo in -the package name or version cannot mis-tag. +commit of `main` and parses `vX.Y.Z` out of its message, so a squash lands the `chore(...): release vX.Y.Z` +title as that commit. A merge commit would make the tip `Merge pull request #…` and the release would +silently not run. After merge: -1. [`release_tag.yml`](.github/workflows/release_tag.yml) tags every unpublished package as - `-vX.Y.Z` (e.g. `stream_feeds-v0.5.2`) and pushes each with the bot PAT. Note the per-package - tag format — older plain `v0.x` tags are history and no longer match the publish trigger. -2. [`release_publish.yml`](.github/workflows/release_publish.yml) fires on that tag push, verifies the tag - version matches the pubspec, publishes over OIDC, then creates a GitHub Release whose body is the - `## X.Y.Z` CHANGELOG section. +1. [`release_tag.yml`](.github/workflows/release_tag.yml) extracts `vX.Y.Z` from the commit message and + pushes the tag with the bot PAT. +2. [`release_publish.yml`](.github/workflows/release_publish.yml) fires on that tag push, runs the dry run, + publishes over OIDC, and creates a GitHub Release with generated notes. -Both workflows are idempotent: re-running against a version already live on pub.dev is a clean no-op, and -`workflow_dispatch` on the tag ref is a safe recovery path. Never tag, publish (`melos run release:pub`), or -create a GitHub Release by hand — CI owns all three. +Re-running the publish workflow is a clean no-op: `release:pub` passes `--no-published`, so a version already +live on pub.dev is skipped, and `workflow_dispatch` on the tag ref is a safe recovery path. Never tag, publish +(`melos run release:pub`), or create a GitHub Release by hand — CI owns all three. Agents: `.claude/skills/release-pr/SKILL.md` walks through this end to end. diff --git a/melos.yaml b/melos.yaml index ea0cca23..8d4259a6 100644 --- a/melos.yaml +++ b/melos.yaml @@ -133,14 +133,13 @@ scripts: description: | Run `pub publish --dry-run` in all packages. - Note: you can also rely on your IDEs Dart Analysis / Issues window. - - Note: scope it to a single package 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. + Publish all packages to pub.dev (OIDC in CI). Called by release_publish. + `--no-published` skips versions already live, which is what makes a + re-run of the publish workflow a clean no-op. generate:all: run: melos run generate:dart && melos run generate:flutter From 0dc7bc8587afbd83db2b59cebd73c5b9b941ef87 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 18 Aug 2026 15:09:55 +0200 Subject: [PATCH 3/6] ci(repo): trim comments to chat's set, fix versioning claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit release_publish.yml's comments are now identical to chat's. release_tag.yml keeps two beyond chat, each explaining code that deliberately differs: the scope-agnostic gate and the env-var indirection for the commit message. Also restores chat's one-line `release:pub` description. AGENTS.md called the repo "independent (per-package)" versioning. That describes melos.yaml's `versioning.mode` key, which only governs `melos version` — never run here, since CHANGELOGs are hand-curated. It reads as a claim about how feeds releases, which is lockstep behind a single `vX.Y.Z` tag. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release_publish.yml | 3 --- .github/workflows/release_tag.yml | 7 ++----- AGENTS.md | 2 +- melos.yaml | 5 +---- 4 files changed, 4 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release_publish.yml b/.github/workflows/release_publish.yml index d38a5b9a..5ea5bb7d 100644 --- a/.github/workflows/release_publish.yml +++ b/.github/workflows/release_publish.yml @@ -59,9 +59,6 @@ jobs: echo "tag=$tag_name" >> "$GITHUB_OUTPUT" echo "prerelease=$is_prerelease" >> "$GITHUB_OUTPUT" - # `make_latest` is left at its default (true): the repo releases in - # lockstep behind a single version, so every release is the latest one. - # (Pre-releases are never marked latest by GitHub regardless.) - name: 🚀 Create GitHub Release uses: softprops/action-gh-release@v3 with: diff --git a/.github/workflows/release_tag.yml b/.github/workflows/release_tag.yml index 17b022eb..16885822 100644 --- a/.github/workflows/release_tag.yml +++ b/.github/workflows/release_tag.yml @@ -11,8 +11,7 @@ concurrency: jobs: release: # Only run this job for commits that indicate a release. Scope-agnostic: - # release commits here are `chore(llc): release vX.Y.Z` as often as - # `chore(repo): …`, and GH expressions have no regex. + # releases land as `chore(llc): release …` as often as `chore(repo): …`. if: "${{ startsWith(github.event.head_commit.message, 'chore(') && contains(github.event.head_commit.message, '): release') }}" runs-on: ubuntu-latest permissions: @@ -20,7 +19,6 @@ jobs: steps: - name: 📚 Checkout branch - # Bot PAT: GITHUB_TOKEN tag pushes don't trigger the publish workflow. uses: actions/checkout@v7 with: fetch-depth: 0 @@ -29,8 +27,7 @@ jobs: - name: 🏷️ Extract Version Tag id: extract_tag shell: bash - # Via env, not inline `${{ }}`: a commit message is untrusted input and - # would otherwise be interpolated straight into the script. + # Via env, not inline `${{ }}` — a commit message is untrusted input. env: COMMIT_MSG: ${{ github.event.head_commit.message }} run: | diff --git a/AGENTS.md b/AGENTS.md index 3c32ca58..4a39371e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -425,7 +425,7 @@ melos run lint:all - SDK uses semantic versioning - Version managed in `packages/stream_feeds/pubspec.yaml` -- Versioning mode: independent (per-package) +- `stream_feeds` is the only published package; releases go out behind a single `vX.Y.Z` tag - Below `1.0.0` the Dart convention shifts every slot down one: a breaking release is a **minor** bump, a feature release is a **patch** bump, and a change with no public API impact is a build (`+1`) bump. See [Package versioning](https://dart.dev/tools/pub/versioning#semantic-versions). diff --git a/melos.yaml b/melos.yaml index 8d4259a6..b213cdc7 100644 --- a/melos.yaml +++ b/melos.yaml @@ -136,10 +136,7 @@ scripts: release:pub: run: melos exec -c 1 --no-published --no-private --order-dependents -- "flutter pub publish -f" - description: | - Publish all packages to pub.dev (OIDC in CI). Called by release_publish. - `--no-published` skips versions already live, which is what makes a - re-run of the publish workflow a clean no-op. + description: Publish all packages to pub.dev. generate:all: run: melos run generate:dart && melos run generate:flutter From 3ef96243ebd59d0e6d28303a2b2ecf5c26c21a6f Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 18 Aug 2026 15:21:32 +0200 Subject: [PATCH 4/6] refactor(repo): consolidate analysis_options on the root file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The example carried its own `analysis_options.yaml` containing only `include: package:flutter_lints/flutter.yaml` — so it was linted against flutter_lints instead of the repo's config, and overrode nothing. Chat has no analysis_options under `packages/` at all; examples inherit the root. Delete it. That also fixes the publishing blocker at its source rather than papering over it: `flutter pub get` injects a platform-exclude block into an *existing* analysis_options.yaml, and this file sits inside the published package, so every bootstrap dirtied the tree and failed `pub publish --dry-run` with exit 65. Verified `flutter pub get` does not recreate the file once deleted. `flutter_lints` existed only to serve that include, and nothing else in the workspace used it — dropped from the example pubspec and melos.yaml, matching chat, which has no flutter_lints anywhere. The example now analyzes under the root config; `dart fix --apply` cleared the 6 lints that surfaced (const constructors, int literal, DecoratedBox). Remaining overrides are all genuine and all `include:` the root: docs (unused_local_variable + doc-sample lints), sample_app, and stream_feeds_test (internal/visible-for-testing member access). Their Flutter-injected exclude blocks stay committed so bootstrap is a no-op — same as chat's sample_app. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 4 +++- melos.yaml | 1 - packages/stream_feeds/example/analysis_options.yaml | 10 ---------- packages/stream_feeds/example/lib/main.dart | 12 ++++++------ packages/stream_feeds/example/pubspec.yaml | 1 - 5 files changed, 9 insertions(+), 19 deletions(-) delete mode 100644 packages/stream_feeds/example/analysis_options.yaml diff --git a/AGENTS.md b/AGENTS.md index 4a39371e..e517769d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -410,7 +410,9 @@ melos run lint:all - `.cursorrules`: Primary development rules for AI assistants - `.cursor/rules/`: Supplementary documentation for specific patterns -- `analysis_options.yaml`: Dart analyzer configuration +- `analysis_options.yaml`: Dart analyzer configuration. The root file is the single source of truth — a + package only gets its own when it genuinely needs to override something, and then it must start with + `include: ` so the root rules still apply. Don't add one just to re-declare defaults. - `melos.yaml`: Monorepo configuration and dependencies - `scripts/generate.sh`: OpenAPI client generation script diff --git a/melos.yaml b/melos.yaml index b213cdc7..a4cf14f1 100644 --- a/melos.yaml +++ b/melos.yaml @@ -57,7 +57,6 @@ command: auto_route_generator: ^10.0.0 build_runner: ^2.4.15 flutter_launcher_icons: ^0.14.4 - flutter_lints: ^6.0.0 freezed: ^3.0.0 injectable_generator: ^3.0.0 json_serializable: ^6.9.5 diff --git a/packages/stream_feeds/example/analysis_options.yaml b/packages/stream_feeds/example/analysis_options.yaml deleted file mode 100644 index 743e05ad..00000000 --- a/packages/stream_feeds/example/analysis_options.yaml +++ /dev/null @@ -1,10 +0,0 @@ -analyzer: - exclude: - - build/** - - android/** - - ios/** - - web/** - - windows/** - - macos/** - - linux/** -include: package:flutter_lints/flutter.yaml diff --git a/packages/stream_feeds/example/lib/main.dart b/packages/stream_feeds/example/lib/main.dart index b3659dbd..9fb9582a 100644 --- a/packages/stream_feeds/example/lib/main.dart +++ b/packages/stream_feeds/example/lib/main.dart @@ -19,7 +19,7 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { - return MaterialApp(title: 'Stream Feeds Example', home: const MyHomePage()); + return const MaterialApp(title: 'Stream Feeds Example', home: MyHomePage()); } } @@ -39,7 +39,7 @@ class _MyHomePageState extends State { super.initState(); client = StreamFeedsClient( apiKey: apiKey, - user: User(id: userId), + user: const User(id: userId), tokenProvider: TokenProvider.static(UserToken(userToken)), ); connectionFuture = client.connect(); @@ -84,7 +84,7 @@ class _MyTimeLineState extends State { @override void initState() { super.initState(); - feed = widget.client.feedFromId(FeedId.timeline(userId)); + feed = widget.client.feedFromId(const FeedId.timeline(userId)); feed.getOrCreate(); } @@ -138,7 +138,7 @@ class ActivityItem extends StatelessWidget { ); return Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -149,7 +149,7 @@ class ActivityItem extends StatelessWidget { backgroundImage: userImage != null ? NetworkImage(userImage) : null, - child: Container( + child: DecoratedBox( decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.3), shape: BoxShape.circle, @@ -160,7 +160,7 @@ class ActivityItem extends StatelessWidget { ), ), ), - SizedBox(width: 16), + const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/packages/stream_feeds/example/pubspec.yaml b/packages/stream_feeds/example/pubspec.yaml index be5c2dee..2427f973 100644 --- a/packages/stream_feeds/example/pubspec.yaml +++ b/packages/stream_feeds/example/pubspec.yaml @@ -16,7 +16,6 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^6.0.0 flutter: uses-material-design: true \ No newline at end of file From 7e862fa97dd6555ffc2801f961ba595b24db3757 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 18 Aug 2026 15:25:39 +0200 Subject: [PATCH 5/6] revert(repo): drop the generated_plugins.cmake churn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unrelated to publishing. `sample_app` is `publish_to: none`, so these files sit outside the published package and never reach `pub publish --dry-run` — unlike the example's analysis_options.yaml, which was a real blocker. CI builds neither linux nor windows. They'll regenerate on whoever's next `flutter pub get` and can land in a PR that has something to do with them. Co-Authored-By: Claude Opus 5 (1M context) --- sample_app/linux/flutter/generated_plugins.cmake | 1 - sample_app/windows/flutter/generated_plugins.cmake | 1 - 2 files changed, 2 deletions(-) diff --git a/sample_app/linux/flutter/generated_plugins.cmake b/sample_app/linux/flutter/generated_plugins.cmake index be1ee3e5..2e1de87a 100644 --- a/sample_app/linux/flutter/generated_plugins.cmake +++ b/sample_app/linux/flutter/generated_plugins.cmake @@ -6,7 +6,6 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST - jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/sample_app/windows/flutter/generated_plugins.cmake b/sample_app/windows/flutter/generated_plugins.cmake index 04f65d2b..664a7422 100644 --- a/sample_app/windows/flutter/generated_plugins.cmake +++ b/sample_app/windows/flutter/generated_plugins.cmake @@ -8,7 +8,6 @@ list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST flutter_local_notifications_windows - jni ) set(PLUGIN_BUNDLED_LIBRARIES) From 82eb7c38084c672f61dbdea7c0f3d8523a12ae5e Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 18 Aug 2026 15:26:49 +0200 Subject: [PATCH 6/6] style(llc): reformat the example at the repo's page width Follow-on from inheriting the root analysis_options: the example was formatted at the default 80 columns via flutter_lints, and the root config sets `page_width: 120`. `melos run format:verify` now passes on it. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_feeds/example/lib/main.dart | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/stream_feeds/example/lib/main.dart b/packages/stream_feeds/example/lib/main.dart index 9fb9582a..b4798caa 100644 --- a/packages/stream_feeds/example/lib/main.dart +++ b/packages/stream_feeds/example/lib/main.dart @@ -146,9 +146,7 @@ class ActivityItem extends StatelessWidget { width: 50, height: 50, child: CircleAvatar( - backgroundImage: userImage != null - ? NetworkImage(userImage) - : null, + backgroundImage: userImage != null ? NetworkImage(userImage) : null, child: DecoratedBox( decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.3),