From a460b6cf0337f3052b232ac979e51706fecdb654 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 29 Jul 2026 17:25:52 +0200 Subject: [PATCH 01/36] feat(reaction): report the pressed item from StreamReactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace StreamReactions.onPressed (a bare VoidCallback shared by every chip) with onReactionPressed, which reports the pressed StreamReactionsItem β€” or null for the cluster/overflow chip, which represents no single reaction. Add an optional StreamReactionsItem.key so callers can identify the pressed item. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/stream_core_flutter/CHANGELOG.md | 6 + .../components/reaction/stream_reactions.dart | 40 ++++--- .../reaction/stream_reactions_test.dart | 103 ++++++++++++++++++ 3 files changed, 135 insertions(+), 14 deletions(-) create mode 100644 packages/stream_core_flutter/test/components/reaction/stream_reactions_test.dart diff --git a/packages/stream_core_flutter/CHANGELOG.md b/packages/stream_core_flutter/CHANGELOG.md index 1d1cf953..98661c2f 100644 --- a/packages/stream_core_flutter/CHANGELOG.md +++ b/packages/stream_core_flutter/CHANGELOG.md @@ -1,3 +1,9 @@ +## Upcoming + +### πŸ›‘ Breaking / Removals + +- Replaced `StreamReactions.onPressed` (a `VoidCallback`) with `onReactionPressed`, which reports the pressed `StreamReactionsItem` β€” or `null` for the cluster/overflow chip, which represents no single reaction. Added an optional `StreamReactionsItem.key` so callers can identify the pressed item. + ## 0.4.1 ### ✨ Features diff --git a/packages/stream_core_flutter/lib/src/components/reaction/stream_reactions.dart b/packages/stream_core_flutter/lib/src/components/reaction/stream_reactions.dart index 85976bc5..ae586929 100644 --- a/packages/stream_core_flutter/lib/src/components/reaction/stream_reactions.dart +++ b/packages/stream_core_flutter/lib/src/components/reaction/stream_reactions.dart @@ -81,7 +81,7 @@ class StreamReactions extends StatelessWidget { double? indent, CrossAxisAlignment? crossAxisAlignment, Clip clipBehavior = Clip.none, - VoidCallback? onPressed, + ValueSetter? onReactionPressed, }) : props = .new( items: items, child: child, @@ -93,7 +93,7 @@ class StreamReactions extends StatelessWidget { indent: indent, crossAxisAlignment: crossAxisAlignment, clipBehavior: clipBehavior, - onPressed: onPressed, + onReactionPressed: onReactionPressed, ); /// Creates segmented reactions where each type is rendered as its own chip. @@ -108,7 +108,7 @@ class StreamReactions extends StatelessWidget { double? indent, CrossAxisAlignment? crossAxisAlignment, Clip clipBehavior = Clip.none, - VoidCallback? onPressed, + ValueSetter? onReactionPressed, }) : props = .new( items: items, child: child, @@ -120,7 +120,7 @@ class StreamReactions extends StatelessWidget { indent: indent, crossAxisAlignment: crossAxisAlignment, clipBehavior: clipBehavior, - onPressed: onPressed, + onReactionPressed: onReactionPressed, ); /// Creates clustered reactions that group all reaction types into one chip. @@ -135,7 +135,7 @@ class StreamReactions extends StatelessWidget { double? indent, CrossAxisAlignment? crossAxisAlignment, Clip clipBehavior = Clip.none, - VoidCallback? onPressed, + ValueSetter? onReactionPressed, }) : props = .new( items: items, child: child, @@ -147,7 +147,7 @@ class StreamReactions extends StatelessWidget { indent: indent, crossAxisAlignment: crossAxisAlignment, clipBehavior: clipBehavior, - onPressed: onPressed, + onReactionPressed: onReactionPressed, ); /// The properties that configure this widget. @@ -202,7 +202,7 @@ class StreamReactionsProps { this.indent, this.crossAxisAlignment, this.clipBehavior = Clip.none, - this.onPressed, + this.onReactionPressed, }); /// The reaction presentation style. @@ -245,11 +245,12 @@ class StreamReactionsProps { /// The clip behavior applied to the layout. final Clip clipBehavior; - /// Called when any reaction chip is tapped. + /// Called when a reaction chip is pressed, with the pressed item. /// - /// In segmented mode, this is used for each visible chip, including the - /// overflow chip. In clustered mode, it is used for the grouped chip. - final VoidCallback? onPressed; + /// In segmented mode, the pressed [StreamReactionsItem] is provided for each + /// visible chip; the overflow chip reports `null`. In clustered mode, the + /// single grouped chip reports `null` since it represents no single item. + final ValueSetter? onReactionPressed; } /// A single reaction item with an emoji widget and optional count. @@ -265,6 +266,7 @@ class StreamReactionsItem { const StreamReactionsItem({ required this.emoji, this.count, + this.key, }); /// The content model describing what to render. @@ -277,6 +279,12 @@ class StreamReactionsItem { /// /// When null, the reaction is treated as having a count of 1. final int? count; + + /// An optional identifier for this item. + /// + /// [StreamReactions.onReactionPressed] reports the pressed item, so callers + /// can set [key] (e.g. a reaction type) to identify which item was pressed. + final String? key; } const _kMaxVisibleSegments = 4; @@ -397,17 +405,19 @@ class DefaultStreamReactions extends StatelessWidget { final overflow = items.skip(maxVisible).toList(); final overflowCount = overflow.sumOf((item) => item.count ?? 1); + final onReactionPressed = props.onReactionPressed; final children = [ for (final item in visible) StreamEmojiChip( emoji: item.emoji, count: showCounts ? item.count ?? 1 : null, - onPressed: props.onPressed, + onPressed: onReactionPressed == null ? null : () => onReactionPressed(item), ), + // The overflow chip aggregates hidden reactions, so it has no single item. if (overflow.isNotEmpty) StreamEmojiChip.overflow( count: overflowCount, - onPressed: props.onPressed, + onPressed: onReactionPressed == null ? null : () => onReactionPressed(null), ), ]; @@ -420,10 +430,12 @@ class DefaultStreamReactions extends StatelessWidget { final visible = items.take(maxVisible).map((item) => item.emoji).toList(); final totalCount = items.sumOf((item) => item.count ?? 1); + // The cluster groups all reactions into one chip, so it has no single item. + final onReactionPressed = props.onReactionPressed; return StreamEmojiChip.cluster( emojis: visible, count: totalCount > 1 ? totalCount : null, - onPressed: props.onPressed, + onPressed: onReactionPressed == null ? null : () => onReactionPressed(null), ); } } diff --git a/packages/stream_core_flutter/test/components/reaction/stream_reactions_test.dart b/packages/stream_core_flutter/test/components/reaction/stream_reactions_test.dart new file mode 100644 index 00000000..aaf304e4 --- /dev/null +++ b/packages/stream_core_flutter/test/components/reaction/stream_reactions_test.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_core_flutter/chat.dart'; + +void main() { + Widget wrap(Widget child) { + return MaterialApp( + home: Theme( + data: ThemeData(extensions: [StreamTheme()]), + child: Scaffold(body: child), + ), + ); + } + + group('StreamReactions.onReactionPressed', () { + testWidgets('segmented reports the pressed item', (tester) async { + StreamReactionsItem? pressed; + var callCount = 0; + await tester.pumpWidget( + wrap( + StreamReactions.segmented( + items: const [ + StreamReactionsItem(emoji: StreamUnicodeEmoji('πŸ‘'), count: 3, key: 'like'), + StreamReactionsItem(emoji: StreamUnicodeEmoji('❀️'), count: 2, key: 'love'), + ], + onReactionPressed: (item) { + pressed = item; + callCount++; + }, + ), + ), + ); + + await tester.tap(find.byType(IconButton).first); + expect(callCount, 1); + expect(pressed?.key, 'like'); + }); + + testWidgets('segmented overflow chip reports null', (tester) async { + StreamReactionsItem? pressed; + var called = false; + await tester.pumpWidget( + wrap( + StreamReactions.segmented( + // Force an overflow chip by limiting visible segments to one. + max: 1, + items: const [ + StreamReactionsItem(emoji: StreamUnicodeEmoji('πŸ‘'), count: 1, key: 'like'), + StreamReactionsItem(emoji: StreamUnicodeEmoji('❀️'), count: 1, key: 'love'), + ], + onReactionPressed: (item) { + pressed = item; + called = true; + }, + ), + ), + ); + + // The overflow "+N" chip is the trailing chip. + await tester.tap(find.byType(IconButton).last); + expect(called, isTrue); + expect(pressed, isNull); + }); + + testWidgets('clustered chip reports null', (tester) async { + StreamReactionsItem? pressed; + var called = false; + await tester.pumpWidget( + wrap( + StreamReactions.clustered( + items: const [ + StreamReactionsItem(emoji: StreamUnicodeEmoji('πŸ‘'), count: 3, key: 'like'), + StreamReactionsItem(emoji: StreamUnicodeEmoji('❀️'), count: 2, key: 'love'), + ], + onReactionPressed: (item) { + pressed = item; + called = true; + }, + ), + ), + ); + + await tester.tap(find.byType(IconButton).first); + expect(called, isTrue); + expect(pressed, isNull); + }); + + testWidgets('chips are non-interactive when onReactionPressed is null', (tester) async { + await tester.pumpWidget( + wrap( + StreamReactions.segmented( + items: const [ + StreamReactionsItem(emoji: StreamUnicodeEmoji('πŸ‘'), count: 3, key: 'like'), + ], + ), + ), + ); + + final button = tester.widget(find.byType(IconButton).first); + expect(button.onPressed, isNull); + }); + }); +} From faab75daa30f7e3b7c83d00253a4fd75e7348f9a Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 29 Jul 2026 17:37:57 +0200 Subject: [PATCH 02/36] chore(reaction): order key first in StreamReactionsItem Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/reaction/stream_reactions.dart | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/stream_core_flutter/lib/src/components/reaction/stream_reactions.dart b/packages/stream_core_flutter/lib/src/components/reaction/stream_reactions.dart index 3ffaac59..e7a851d6 100644 --- a/packages/stream_core_flutter/lib/src/components/reaction/stream_reactions.dart +++ b/packages/stream_core_flutter/lib/src/components/reaction/stream_reactions.dart @@ -243,11 +243,17 @@ class StreamReactionsProps { class StreamReactionsItem { /// Creates a reaction item. const StreamReactionsItem({ + this.key, required this.emoji, this.count, - this.key, }); + /// An optional identifier for this item. + /// + /// [StreamReactions.onReactionPressed] reports the pressed item, so callers + /// can set [key] (e.g. a reaction type) to identify which item was pressed. + final String? key; + /// The content model describing what to render. /// /// Typically a [StreamUnicodeEmoji] (e.g. `StreamUnicodeEmoji('πŸ‘')`) @@ -258,12 +264,6 @@ class StreamReactionsItem { /// /// When null, the reaction is treated as having a count of 1. final int? count; - - /// An optional identifier for this item. - /// - /// [StreamReactions.onReactionPressed] reports the pressed item, so callers - /// can set [key] (e.g. a reaction type) to identify which item was pressed. - final String? key; } const _kMaxVisibleSegments = 4; From ad009d16d6ec19e9cdf96cac7e8ae132d1999c18 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 29 Jul 2026 17:39:03 +0200 Subject: [PATCH 03/36] chore(reaction): hoist _colorScheme in _StreamReactionsThemeDefaults Co-Authored-By: Claude Opus 4.8 (1M context) --- .../lib/src/components/reaction/stream_reactions.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/stream_core_flutter/lib/src/components/reaction/stream_reactions.dart b/packages/stream_core_flutter/lib/src/components/reaction/stream_reactions.dart index e7a851d6..171e9e29 100644 --- a/packages/stream_core_flutter/lib/src/components/reaction/stream_reactions.dart +++ b/packages/stream_core_flutter/lib/src/components/reaction/stream_reactions.dart @@ -443,6 +443,7 @@ class _StreamReactionsThemeDefaults extends StreamReactionsThemeData { late final _spacing = _context.streamSpacing; late final _textTheme = _context.streamTextTheme; + late final _colorScheme = _context.streamColorScheme; @override double get spacing => _spacing.xxs; @@ -463,7 +464,7 @@ class _StreamReactionsThemeDefaults extends StreamReactionsThemeData { maximumSize: const Size.fromHeight(24), emojiSize: StreamEmojiSize.sm.value, elevation: .all(overlap ? 3 : 0), - backgroundColor: .all(_context.streamColorScheme.backgroundElevation2), + backgroundColor: .all(_colorScheme.backgroundElevation2), textStyle: .all(_textTheme.numericMd.copyWith(fontFeatures: const [.tabularFigures()])), padding: .symmetric(vertical: _spacing.xxxs, horizontal: _spacing.xs), ); From 8d0109dc5413059f0d14e1566353e4b4b946e53a Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 29 Jul 2026 17:45:55 +0200 Subject: [PATCH 04/36] feat(reaction): add OnReactionItemPressed typedef; showcase onReactionPressed in gallery Extract the callback type into OnReactionItemPressed (mirrors OnReactionItemPicked). Migrate the design system gallery to onReactionPressed and demonstrate it by showing a snackbar with the tapped emoji. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../message/stream_message_content.dart | 4 +-- .../components/reaction/stream_reactions.dart | 28 +++++++++++++++---- .../components/reaction/stream_reactions.dart | 12 +++++--- 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/apps/design_system_gallery/lib/components/message/stream_message_content.dart b/apps/design_system_gallery/lib/components/message/stream_message_content.dart index 92e4d79a..a1822704 100644 --- a/apps/design_system_gallery/lib/components/message/stream_message_content.dart +++ b/apps/design_system_gallery/lib/components/message/stream_message_content.dart @@ -102,7 +102,7 @@ Widget buildStreamMessageContentPlayground(BuildContext context) { overlap: reactionOverlap, alignment: reactionOverlap ? .end : .start, indent: reactionOverlap ? 8 : null, - onPressed: () {}, + onReactionPressed: (_) {}, child: child, ), StreamReactionsType.clustered => StreamReactions.clustered( @@ -111,7 +111,7 @@ Widget buildStreamMessageContentPlayground(BuildContext context) { overlap: reactionOverlap, alignment: reactionOverlap ? .end : .start, indent: reactionOverlap ? 8 : null, - onPressed: () {}, + onReactionPressed: (_) {}, child: child, ), }; diff --git a/apps/design_system_gallery/lib/components/reaction/stream_reactions.dart b/apps/design_system_gallery/lib/components/reaction/stream_reactions.dart index 20463543..aba7a514 100644 --- a/apps/design_system_gallery/lib/components/reaction/stream_reactions.dart +++ b/apps/design_system_gallery/lib/components/reaction/stream_reactions.dart @@ -100,7 +100,16 @@ Widget buildStreamReactionsPlayground(BuildContext context) { max: max, overlap: overlap, indent: indent, - onPressed: () => _showSnack(context, 'Reaction tapped'), + onReactionPressed: (item) { + final emoji = switch (item?.emoji) { + StreamUnicodeEmoji(:final emoji) => emoji, + _ => null, + }; + _showSnack( + context, + emoji != null ? 'Tapped $emoji' : 'Tapped all reactions', + ); + }, child: bubble, ), StreamReactionsType.clustered => StreamReactions.clustered( @@ -111,7 +120,16 @@ Widget buildStreamReactionsPlayground(BuildContext context) { max: max, overlap: overlap, indent: indent, - onPressed: () => _showSnack(context, 'Reaction tapped'), + onReactionPressed: (item) { + final emoji = switch (item?.emoji) { + StreamUnicodeEmoji(:final emoji) => emoji, + _ => null, + }; + _showSnack( + context, + emoji != null ? 'Tapped $emoji' : 'Tapped all reactions', + ); + }, child: bubble, ), }; @@ -584,7 +602,7 @@ class _ShowcaseSection extends StatelessWidget { max: t.max, overlap: t.overlap, child: bubble, - onPressed: () {}, + onReactionPressed: (_) {}, ), StreamReactionsType.clustered => StreamReactions.clustered( items: t.items, @@ -592,7 +610,7 @@ class _ShowcaseSection extends StatelessWidget { max: t.max, overlap: t.overlap, child: bubble, - onPressed: () {}, + onReactionPressed: (_) {}, ), }, ), @@ -645,7 +663,7 @@ class _EmojiOnlyShowcaseSection extends StatelessWidget { ? StreamReactionsAlignment.end : StreamReactionsAlignment.start, indent: position == StreamReactionsPosition.header ? 8 : null, - onPressed: () {}, + onReactionPressed: (_) {}, child: messageText, ); diff --git a/packages/stream_core_flutter/lib/src/components/reaction/stream_reactions.dart b/packages/stream_core_flutter/lib/src/components/reaction/stream_reactions.dart index 171e9e29..90909d57 100644 --- a/packages/stream_core_flutter/lib/src/components/reaction/stream_reactions.dart +++ b/packages/stream_core_flutter/lib/src/components/reaction/stream_reactions.dart @@ -81,7 +81,7 @@ class StreamReactions extends StatelessWidget { double? indent, CrossAxisAlignment? crossAxisAlignment, Clip clipBehavior = Clip.none, - ValueSetter? onReactionPressed, + OnReactionItemPressed? onReactionPressed, }) : props = .new( items: items, child: child, @@ -108,7 +108,7 @@ class StreamReactions extends StatelessWidget { double? indent, CrossAxisAlignment? crossAxisAlignment, Clip clipBehavior = Clip.none, - ValueSetter? onReactionPressed, + OnReactionItemPressed? onReactionPressed, }) : props = .new( items: items, child: child, @@ -135,7 +135,7 @@ class StreamReactions extends StatelessWidget { double? indent, CrossAxisAlignment? crossAxisAlignment, Clip clipBehavior = Clip.none, - ValueSetter? onReactionPressed, + OnReactionItemPressed? onReactionPressed, }) : props = .new( items: items, child: child, @@ -229,7 +229,7 @@ class StreamReactionsProps { /// In segmented mode, the pressed [StreamReactionsItem] is provided for each /// visible chip; the overflow chip reports `null`. In clustered mode, the /// single grouped chip reports `null` since it represents no single item. - final ValueSetter? onReactionPressed; + final OnReactionItemPressed? onReactionPressed; } /// A single reaction item with an emoji widget and optional count. @@ -266,6 +266,10 @@ class StreamReactionsItem { final int? count; } +/// Callback when a reaction item is pressed, or `null` for a chip that +/// represents no single item (the cluster or overflow chip). +typedef OnReactionItemPressed = ValueSetter; + const _kMaxVisibleSegments = 4; const _kDefaultStripIndent = 8.0; From 77d1cb7d35fe157a5b27a2650d3a11bf4995f11b Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 29 Jul 2026 17:46:54 +0200 Subject: [PATCH 05/36] docs(reaction): match sibling typedef doc style for OnReactionItemPressed Co-Authored-By: Claude Opus 4.8 (1M context) --- .../lib/src/components/reaction/stream_reactions.dart | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/stream_core_flutter/lib/src/components/reaction/stream_reactions.dart b/packages/stream_core_flutter/lib/src/components/reaction/stream_reactions.dart index 90909d57..f4e3ba12 100644 --- a/packages/stream_core_flutter/lib/src/components/reaction/stream_reactions.dart +++ b/packages/stream_core_flutter/lib/src/components/reaction/stream_reactions.dart @@ -12,6 +12,9 @@ import '../controls/stream_emoji_chip.dart'; import '../message_layout/stream_message_alignment.dart'; import '../message_layout/stream_message_layout.dart'; +/// Callback when a reaction item is pressed. +typedef OnReactionItemPressed = ValueSetter; + /// Displays reactions as either individual chips or a single grouped chip. /// /// Use [StreamReactions.segmented] to render each reaction type as its own @@ -266,10 +269,6 @@ class StreamReactionsItem { final int? count; } -/// Callback when a reaction item is pressed, or `null` for a chip that -/// represents no single item (the cluster or overflow chip). -typedef OnReactionItemPressed = ValueSetter; - const _kMaxVisibleSegments = 4; const _kDefaultStripIndent = 8.0; From 3e2363179713756aab3b60bc14f0ac500569cb3d Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 30 Jul 2026 11:48:36 +0200 Subject: [PATCH 06/36] feat(reaction): deprecate StreamReactions.onPressed instead of removing it Keep onPressed (VoidCallback) as a deprecated fallback alongside the new onReactionPressed, so the change is non-breaking (mirrors the chat onReactionsTap deprecation). onReactionPressed wins when both are set; onPressed is folded in via the chip callback. Requires a few deprecated_member_use ignores since core runs --fatal-infos + deprecated_consistency. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/stream_core_flutter/CHANGELOG.md | 9 ++-- .../components/reaction/stream_reactions.dart | 45 ++++++++++++++++--- .../reaction/stream_reactions_test.dart | 18 ++++++++ 3 files changed, 62 insertions(+), 10 deletions(-) diff --git a/packages/stream_core_flutter/CHANGELOG.md b/packages/stream_core_flutter/CHANGELOG.md index 3c25dc98..c3dddf27 100644 --- a/packages/stream_core_flutter/CHANGELOG.md +++ b/packages/stream_core_flutter/CHANGELOG.md @@ -1,13 +1,14 @@ ## Upcoming -### πŸ›‘ Breaking / Removals - -- Replaced `StreamReactions.onPressed` (a `VoidCallback`) with `onReactionPressed`, which reports the pressed `StreamReactionsItem` β€” or `null` for the cluster/overflow chip, which represents no single reaction. Added an optional `StreamReactionsItem.key` so callers can identify the pressed item. - ### ✨ Features +- Added `StreamReactions.onReactionPressed`, which reports the pressed `StreamReactionsItem` β€” or `null` for the cluster/overflow chip, which represents no single reaction. Added an optional `StreamReactionsItem.key` so callers can identify the pressed item. - Added `chipStyle` to `StreamReactionsThemeData` for overriding the per-reaction chip appearance (background, size, etc.); it is merged over the default reaction chip style. +### ⚠️ Deprecated + +- Deprecated `StreamReactions.onPressed` in favor of `onReactionPressed`. + ## 0.4.1 ### ✨ Features diff --git a/packages/stream_core_flutter/lib/src/components/reaction/stream_reactions.dart b/packages/stream_core_flutter/lib/src/components/reaction/stream_reactions.dart index f4e3ba12..46f0fc3f 100644 --- a/packages/stream_core_flutter/lib/src/components/reaction/stream_reactions.dart +++ b/packages/stream_core_flutter/lib/src/components/reaction/stream_reactions.dart @@ -84,6 +84,8 @@ class StreamReactions extends StatelessWidget { double? indent, CrossAxisAlignment? crossAxisAlignment, Clip clipBehavior = Clip.none, + @Deprecated('Use onReactionPressed instead. onReactionPressed reports the pressed StreamReactionsItem.') + VoidCallback? onPressed, OnReactionItemPressed? onReactionPressed, }) : props = .new( items: items, @@ -96,6 +98,8 @@ class StreamReactions extends StatelessWidget { indent: indent, crossAxisAlignment: crossAxisAlignment, clipBehavior: clipBehavior, + // ignore: deprecated_member_use_from_same_package + onPressed: onPressed, onReactionPressed: onReactionPressed, ); @@ -111,6 +115,8 @@ class StreamReactions extends StatelessWidget { double? indent, CrossAxisAlignment? crossAxisAlignment, Clip clipBehavior = Clip.none, + @Deprecated('Use onReactionPressed instead. onReactionPressed reports the pressed StreamReactionsItem.') + VoidCallback? onPressed, OnReactionItemPressed? onReactionPressed, }) : props = .new( items: items, @@ -123,6 +129,8 @@ class StreamReactions extends StatelessWidget { indent: indent, crossAxisAlignment: crossAxisAlignment, clipBehavior: clipBehavior, + // ignore: deprecated_member_use_from_same_package + onPressed: onPressed, onReactionPressed: onReactionPressed, ); @@ -138,6 +146,8 @@ class StreamReactions extends StatelessWidget { double? indent, CrossAxisAlignment? crossAxisAlignment, Clip clipBehavior = Clip.none, + @Deprecated('Use onReactionPressed instead. onReactionPressed reports the pressed StreamReactionsItem.') + VoidCallback? onPressed, OnReactionItemPressed? onReactionPressed, }) : props = .new( items: items, @@ -150,6 +160,8 @@ class StreamReactions extends StatelessWidget { indent: indent, crossAxisAlignment: crossAxisAlignment, clipBehavior: clipBehavior, + // ignore: deprecated_member_use_from_same_package + onPressed: onPressed, onReactionPressed: onReactionPressed, ); @@ -184,8 +196,14 @@ class StreamReactionsProps { this.indent, this.crossAxisAlignment, this.clipBehavior = Clip.none, + @Deprecated('Use onReactionPressed instead. onReactionPressed reports the pressed StreamReactionsItem.') + this.onPressed, this.onReactionPressed, - }); + }) : assert( + onPressed == null || onReactionPressed == null, + 'Only one of onPressed or onReactionPressed can be provided. ' + 'Prefer onReactionPressed; onPressed is deprecated.', + ); /// The reaction presentation style. final StreamReactionsType type; @@ -227,6 +245,13 @@ class StreamReactionsProps { /// The clip behavior applied to the layout. final Clip clipBehavior; + /// Called when any reaction chip is pressed. + /// + /// Prefer [onReactionPressed], which also reports the pressed + /// [StreamReactionsItem]. + @Deprecated('Use onReactionPressed instead. onReactionPressed reports the pressed StreamReactionsItem.') + final VoidCallback? onPressed; + /// Called when a reaction chip is pressed, with the pressed item. /// /// In segmented mode, the pressed [StreamReactionsItem] is provided for each @@ -398,19 +423,18 @@ class DefaultStreamReactions extends StatelessWidget { final overflow = items.skip(maxVisible).toList(); final overflowCount = overflow.sumOf((item) => item.count ?? 1); - final onReactionPressed = props.onReactionPressed; final children = [ for (final item in visible) StreamEmojiChip( emoji: item.emoji, count: showCounts ? item.count ?? 1 : null, - onPressed: onReactionPressed == null ? null : () => onReactionPressed(item), + onPressed: _chipCallback(item), ), // The overflow chip aggregates hidden reactions, so it has no single item. if (overflow.isNotEmpty) StreamEmojiChip.overflow( count: overflowCount, - onPressed: onReactionPressed == null ? null : () => onReactionPressed(null), + onPressed: _chipCallback(null), ), ]; @@ -424,13 +448,22 @@ class DefaultStreamReactions extends StatelessWidget { final totalCount = items.sumOf((item) => item.count ?? 1); // The cluster groups all reactions into one chip, so it has no single item. - final onReactionPressed = props.onReactionPressed; return StreamEmojiChip.cluster( emojis: visible, count: totalCount > 1 ? totalCount : null, - onPressed: onReactionPressed == null ? null : () => onReactionPressed(null), + onPressed: _chipCallback(null), ); } + + // Resolves a chip's tap callback, preferring the item-aware + // [StreamReactions.onReactionPressed] and falling back to the deprecated + // [StreamReactions.onPressed]. + VoidCallback? _chipCallback(StreamReactionsItem? item) { + final onReactionPressed = props.onReactionPressed; + if (onReactionPressed != null) return () => onReactionPressed(item); + // ignore: deprecated_member_use_from_same_package + return props.onPressed; + } } // Context-aware default values for [StreamReactionsThemeData]. diff --git a/packages/stream_core_flutter/test/components/reaction/stream_reactions_test.dart b/packages/stream_core_flutter/test/components/reaction/stream_reactions_test.dart index 49d0dfc0..2a7694d9 100644 --- a/packages/stream_core_flutter/test/components/reaction/stream_reactions_test.dart +++ b/packages/stream_core_flutter/test/components/reaction/stream_reactions_test.dart @@ -252,5 +252,23 @@ void main() { final button = tester.widget(find.byType(IconButton).first); expect(button.onPressed, isNull); }); + + testWidgets('deprecated onPressed still fires when tapped', (tester) async { + var count = 0; + await tester.pumpWidget( + wrap( + StreamReactions.segmented( + items: const [ + StreamReactionsItem(emoji: StreamUnicodeEmoji('πŸ‘'), count: 3, key: 'like'), + ], + // ignore: deprecated_member_use_from_same_package + onPressed: () => count++, + ), + ), + ); + + await tester.tap(find.byType(IconButton).first); + expect(count, 1); + }); }); } From 40ccd8ac12447bce1af558171d51d69716596090 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 30 Jul 2026 11:55:22 +0200 Subject: [PATCH 07/36] refactor(reaction): drop the deprecated_member_use ignores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deprecate only the public StreamReactions constructor onPressed params (which is what warns callers) and leave the internal StreamReactionsProps field un-annotated, mirroring the chat StreamMessageItem pattern. Referencing the deprecated ctor param in its own initializer isn't flagged, and internal props access is clean β€” so no // ignore comments are needed in lib. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../lib/src/components/reaction/stream_reactions.dart | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/stream_core_flutter/lib/src/components/reaction/stream_reactions.dart b/packages/stream_core_flutter/lib/src/components/reaction/stream_reactions.dart index 46f0fc3f..48cc4f4b 100644 --- a/packages/stream_core_flutter/lib/src/components/reaction/stream_reactions.dart +++ b/packages/stream_core_flutter/lib/src/components/reaction/stream_reactions.dart @@ -98,7 +98,6 @@ class StreamReactions extends StatelessWidget { indent: indent, crossAxisAlignment: crossAxisAlignment, clipBehavior: clipBehavior, - // ignore: deprecated_member_use_from_same_package onPressed: onPressed, onReactionPressed: onReactionPressed, ); @@ -129,7 +128,6 @@ class StreamReactions extends StatelessWidget { indent: indent, crossAxisAlignment: crossAxisAlignment, clipBehavior: clipBehavior, - // ignore: deprecated_member_use_from_same_package onPressed: onPressed, onReactionPressed: onReactionPressed, ); @@ -160,7 +158,6 @@ class StreamReactions extends StatelessWidget { indent: indent, crossAxisAlignment: crossAxisAlignment, clipBehavior: clipBehavior, - // ignore: deprecated_member_use_from_same_package onPressed: onPressed, onReactionPressed: onReactionPressed, ); @@ -196,7 +193,6 @@ class StreamReactionsProps { this.indent, this.crossAxisAlignment, this.clipBehavior = Clip.none, - @Deprecated('Use onReactionPressed instead. onReactionPressed reports the pressed StreamReactionsItem.') this.onPressed, this.onReactionPressed, }) : assert( @@ -249,7 +245,6 @@ class StreamReactionsProps { /// /// Prefer [onReactionPressed], which also reports the pressed /// [StreamReactionsItem]. - @Deprecated('Use onReactionPressed instead. onReactionPressed reports the pressed StreamReactionsItem.') final VoidCallback? onPressed; /// Called when a reaction chip is pressed, with the pressed item. @@ -461,7 +456,6 @@ class DefaultStreamReactions extends StatelessWidget { VoidCallback? _chipCallback(StreamReactionsItem? item) { final onReactionPressed = props.onReactionPressed; if (onReactionPressed != null) return () => onReactionPressed(item); - // ignore: deprecated_member_use_from_same_package return props.onPressed; } } From 7a2ae4a4900b2e1d00442876da32eff87faf5ebd Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 30 Jul 2026 16:56:41 +0200 Subject: [PATCH 08/36] ci(repo): add automated per-package pub.dev publishing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tag + publish workflows for the repo's independent per-package versioning, so merging a release PR publishes each bumped package to pub.dev via OIDC (no stored credentials) and cuts a GitHub Release. - release_tag.yml: on a `chore(...): release` merge to main, tag every package whose current version is not yet on pub.dev (`-vX.Y.Z`, derived from package state β€” robust to multi-package release PRs and title typos) and push the tags dependency-first. - release_publish.yml: on a `-vX.Y.Z` tag push (+ workflow_dispatch for re-runs), verify the tag against the pubspec, publish that one package (OIDC), and create a per-package GitHub Release from its CHANGELOG section. A dependency gate waits until each in-workspace dependency is live on pub.dev before publishing, guaranteeing dependent order without a manual re-run. Releases are never marked "latest" since packages version independently. - STYLE_GUIDE.md: document the release flow. Manual setup (bot token + pub.dev config) done in FLU-638. Closes FLU-636. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release_publish.yml | 184 ++++++++++++++++++++++++++ .github/workflows/release_tag.yml | 70 ++++++++++ STYLE_GUIDE.md | 45 +++++++ 3 files changed, 299 insertions(+) create mode 100644 .github/workflows/release_publish.yml create mode 100644 .github/workflows/release_tag.yml diff --git a/.github/workflows/release_publish.yml b/.github/workflows/release_publish.yml new file mode 100644 index 00000000..6353a7ab --- /dev/null +++ b/.github/workflows/release_publish.yml @@ -0,0 +1,184 @@ +name: release_publish + +on: + push: + tags: + - '*-v[0-9]+.[0-9]+.[0-9]+' # per-package release tags, e.g. stream_core-v0.4.0 + - '*-v[0-9]+.[0-9]+.[0-9]+-*' # per-package pre-release tags, e.g. stream_core-v0.4.0-beta.1 + workflow_dispatch: # Allow manual re-runs against an existing tag ref + +concurrency: + # Keyed on the tag ref. cancel-in-progress must stay false so a manual re-run + # does not cancel an in-flight publish of the same tag. + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + permissions: + contents: write # Required to create the GitHub Release + id-token: write # Required for OIDC authentication to pub.dev + runs-on: ubuntu-latest + steps: + - name: πŸ“š Checkout branch + uses: actions/checkout@v6 + 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; version may carry a pre-release suffix. + if [[ ! "$ref" =~ ^([a-z0-9_]+)-v(.+)$ ]]; then + echo "::error ::Tag '$ref' does not match '-v'." + exit 1 + fi + + pkg="${BASH_REMATCH[1]}" + version="${BASH_REMATCH[2]}" + + pubspec="packages/$pkg/pubspec.yaml" + if [[ ! -f "$pubspec" ]]; then + echo "::error ::No package found at $pubspec." + exit 1 + fi + + # Guard against a stray tag: the pubspec version must equal the tag. + # pub.dev's OIDC check enforces this too, but failing here is clearer. + 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 + + is_prerelease=$([[ "$version" == *-* ]] && echo true || echo false) + + { + echo "package=$pkg" + echo "version=$version" + echo "prerelease=$is_prerelease" + } >> "$GITHUB_OUTPUT" + + # setup-dart provisions the OIDC token used to authenticate with pub.dev. + # It must run before flutter-action, which bundles Dart but not the OIDC setup. + - name: 🎯 Setup Dart + 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 + + # melos publish only ever touches unpublished versions, so a re-run of an + # already-published tag is a clean no-op. Scoped to the single tagged package. + - name: 🌡 Dry Run + run: melos publish --dry-run --yes --no-published --no-private --scope="${{ steps.parse.outputs.package }}" + + # Guarantee dependent-order publishing. Each package publishes in its own + # run (OIDC binds one tag ref β†’ one package), so when a release bumps both + # a package and something it depends on, the dependent's run can start + # before the dependency is live on pub.dev and its publish would fail + # resolution. Rather than lean on a manual re-run, wait here until every + # in-workspace dependency this package needs is live at the version the + # workspace pins. Common case (dependency already published, or none): + # returns immediately. + - name: ⏳ Wait for in-workspace dependencies + shell: bash + run: | + set -euo pipefail + + pkg="${{ steps.parse.outputs.package }}" + + # This package's workspace dependencies, from melos's own dependency + # graph (`{package: [deps]}`). awk keeps only the JSON object, dropping + # any trailing log lines; it drains the whole stream (no early exit) so + # melos never takes a SIGPIPE that pipefail would turn into a failure. + graph="$(melos list --graph 2>/dev/null | awk '/^\{/{f=1} f{print} /^\}$/{f=0}')" + mapfile -t deps < <(printf '%s' "$graph" | jq -r --arg p "$pkg" '.[$p] // [] | .[]') + + if [[ "${#deps[@]}" -eq 0 ]]; then + echo "βœ… $pkg has no in-workspace dependencies; nothing to wait for." + exit 0 + fi + + for dep in "${deps[@]}"; do + dep_pubspec="packages/$dep/pubspec.yaml" + # A dependency outside packages/ (e.g. a private app) is never on + # pub.dev and cannot be a real dependency of a published package. + [[ -f "$dep_pubspec" ]] || continue + + want="$(grep -E '^version:' "$dep_pubspec" | head -n1 | sed -E 's/^version:[[:space:]]*//')" + echo "⏳ $pkg depends on $dep β€” waiting for $dep v$want on pub.dev…" + + deadline=$((SECONDS + 900)) # 15 minutes + until curl -sfL -o /dev/null "https://pub.dev/api/packages/$dep/versions/$want"; do + if (( SECONDS >= deadline )); then + echo "::error ::Timed out waiting for $dep v$want on pub.dev. Once $dep is published, re-run this workflow (workflow_dispatch on this tag)." + exit 1 + fi + echo " …not live yet; retrying in 15s" + sleep 15 + done + echo "βœ… $dep v$want is live." + done + + - name: πŸ“’ Publish to pub.dev + run: melos publish --no-dry-run --yes --no-published --no-private --scope="${{ steps.parse.outputs.package }}" + + - name: πŸ“ Extract CHANGELOG section + id: notes + shell: bash + run: | + set -euo pipefail + + pkg="${{ steps.parse.outputs.package }}" + version="${{ steps.parse.outputs.version }}" + changelog="packages/$pkg/CHANGELOG.md" + notes_file="$RUNNER_TEMP/release_notes.md" + + # Extract the `## ` section. Exact heading compare β€” versions + # carry dots and pre-release hyphens, so no regex. Not + # generate_release_notes: that would list every repo commit since the + # previous tag of *any* package. + if [[ -f "$changelog" ]]; then + awk -v version="$version" ' + $0 == "## " version { capture = 1; next } + capture && /^## / { exit } + capture { print } + ' "$changelog" > "$notes_file" + fi + + # Publishing already happened and is irreversible, so a missing or + # renamed heading must not fail the release β€” fall back to a link. + if [[ ! -s "$notes_file" ]]; then + echo "See [CHANGELOG](https://github.com/${GITHUB_REPOSITORY}/blob/${GITHUB_REF_NAME}/packages/$pkg/CHANGELOG.md)." > "$notes_file" + fi + + 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 }} + # Packages are versioned independently, so no single release is "the + # repo's latest" β€” a stream_thumbnail patch must not outrank a + # stream_core_flutter release for the Latest badge. Keep every + # per-package release off it. + make_latest: false + 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..6660a84d --- /dev/null +++ b/.github/workflows/release_tag.yml @@ -0,0 +1,70 @@ +name: release_tag + +on: + push: + branches: [main] + +concurrency: + # Keyed on the branch ref, so back-to-back release merges share this group. + # cancel-in-progress must stay false: cancelling a run mid tag-push would + # silently drop a release. + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + # Run only for release commits. GitHub expressions have no regex, so the + # `chore(): release ...` prefix is matched with startsWith + contains. + if: "${{ startsWith(github.event.head_commit.message, 'chore(') && contains(github.event.head_commit.message, '): release') }}" + runs-on: ubuntu-latest + permissions: + contents: write # Required to create and push tags + steps: + - name: πŸ“š Checkout branch + # Bot PAT (not the default GITHUB_TOKEN): tags pushed with GITHUB_TOKEN + # do not trigger the `on: push: tags` 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 + # melos exec --no-published queries pub.dev, so melos must be installed. + # No bootstrap needed β€” package discovery does not require it. + run: flutter pub global activate melos + + - name: 🏷️ Tag unpublished packages + shell: bash + run: | + set -euo pipefail + + git config user.name "Stream SDK Bot" + git config user.email "60655709+Stream-SDK-Bot@users.noreply.github.com" + + # Tag every publishable package whose current version is not yet on + # pub.dev. Tags are derived from package state, not parsed from the + # commit message, so multi-package release PRs and title typos are + # handled correctly. `|| true`: an unchanged package may already + # carry its tag from a previous release. + # + # Single quotes are intentional: the string is passed verbatim to + # melos, which expands $MELOS_PACKAGE_NAME/$MELOS_PACKAGE_VERSION in + # each package's child shell. + # shellcheck disable=SC2016 + melos exec --no-published --no-private -- \ + 'git tag "$MELOS_PACKAGE_NAME-v$MELOS_PACKAGE_VERSION" || true' + + # Push tags one at a time (`-c 1`) β€” bulk pushes can drop individual + # tag events, which would skip the publish workflow β€” and in + # dependency order (`--order-dependents`: dependencies first) so a + # dependent's publish is triggered after its dependency's, minimising + # the wait in release_publish's dependency gate. Idempotent: pushing a + # tag that already exists on the remote is a no-op. + # shellcheck disable=SC2016 + melos exec -c 1 --no-published --no-private --order-dependents -- \ + 'git push origin "$MELOS_PACKAGE_NAME-v$MELOS_PACKAGE_VERSION"' diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index db5a4754..0191ba57 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -1373,6 +1373,51 @@ 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 `-v` (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` for a multi-package release, or +`chore(): release 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. + +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 β€” `-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 automatic.** `stream_core_flutter` depends on `stream_core`, +and each package publishes in its own run, so releasing both together could +otherwise let the dependent publish before its dependency is on pub.dev. +`release_publish.yml` prevents this: before publishing, it waits until every +in-workspace dependency it needs is live on pub.dev at the pinned version (and +`release_tag.yml` pushes tags dependency-first to keep that wait short). No +manual step is needed. If a dependency's own publish genuinely fails, the +dependent times out after 15 minutes; fix the dependency, then re-run the +dependent's workflow (`workflow_dispatch` on its tag) β€” publishing is idempotent, +so re-runs are safe. + ## Where to look when you're stuck From efa3eb41a672cf7eaac4b52f44106a9e6f6f700d Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 30 Jul 2026 18:36:25 +0200 Subject: [PATCH 09/36] refactor(repo): move release commands into melos.yaml scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the tag/publish commands from the workflows into `release:tag`, `release:pub:dry`, and `release:pub` melos scripts (matching the existing `lint:pub` convention) so they're documented in one place and runnable locally. Workflows now call `melos run …`. All three scripts publish/tag in dependency order (`--order-dependents`), so a dependency is always tagged/published before anything that depends on it β€” correct even when `release:pub` is run unscoped. The publish workflow scopes each run to one package via the MELOS_PACKAGES env var (melos's `--scope` flag can't read an env var from a script). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release_publish.yml | 11 ++++++--- .github/workflows/release_tag.yml | 27 +++++----------------- melos.yaml | 32 +++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 25 deletions(-) diff --git a/.github/workflows/release_publish.yml b/.github/workflows/release_publish.yml index 6353a7ab..666ef989 100644 --- a/.github/workflows/release_publish.yml +++ b/.github/workflows/release_publish.yml @@ -83,9 +83,12 @@ jobs: run: melos bootstrap --verbose # melos publish only ever touches unpublished versions, so a re-run of an - # already-published tag is a clean no-op. Scoped to the single tagged package. + # already-published tag is a clean no-op. MELOS_PACKAGES scopes the + # `release:pub*` scripts (melos.yaml) to just the tagged package. - name: 🌡 Dry Run - run: melos publish --dry-run --yes --no-published --no-private --scope="${{ steps.parse.outputs.package }}" + run: melos run release:pub:dry + env: + MELOS_PACKAGES: ${{ steps.parse.outputs.package }} # Guarantee dependent-order publishing. Each package publishes in its own # run (OIDC binds one tag ref β†’ one package), so when a release bumps both @@ -136,7 +139,9 @@ jobs: done - name: πŸ“’ Publish to pub.dev - run: melos publish --no-dry-run --yes --no-published --no-private --scope="${{ steps.parse.outputs.package }}" + run: melos run release:pub + env: + MELOS_PACKAGES: ${{ steps.parse.outputs.package }} - name: πŸ“ Extract CHANGELOG section id: notes diff --git a/.github/workflows/release_tag.yml b/.github/workflows/release_tag.yml index 6660a84d..1955ceb3 100644 --- a/.github/workflows/release_tag.yml +++ b/.github/workflows/release_tag.yml @@ -46,25 +46,8 @@ jobs: git config user.name "Stream SDK Bot" git config user.email "60655709+Stream-SDK-Bot@users.noreply.github.com" - # Tag every publishable package whose current version is not yet on - # pub.dev. Tags are derived from package state, not parsed from the - # commit message, so multi-package release PRs and title typos are - # handled correctly. `|| true`: an unchanged package may already - # carry its tag from a previous release. - # - # Single quotes are intentional: the string is passed verbatim to - # melos, which expands $MELOS_PACKAGE_NAME/$MELOS_PACKAGE_VERSION in - # each package's child shell. - # shellcheck disable=SC2016 - melos exec --no-published --no-private -- \ - 'git tag "$MELOS_PACKAGE_NAME-v$MELOS_PACKAGE_VERSION" || true' - - # Push tags one at a time (`-c 1`) β€” bulk pushes can drop individual - # tag events, which would skip the publish workflow β€” and in - # dependency order (`--order-dependents`: dependencies first) so a - # dependent's publish is triggered after its dependency's, minimising - # the wait in release_publish's dependency gate. Idempotent: pushing a - # tag that already exists on the remote is a no-op. - # shellcheck disable=SC2016 - melos exec -c 1 --no-published --no-private --order-dependents -- \ - 'git push origin "$MELOS_PACKAGE_NAME-v$MELOS_PACKAGE_VERSION"' + # Tag every package whose version is not yet on pub.dev and push the + # tags in dependency order. See the `release:tag` script in melos.yaml. + # Tags are derived from package state, not the commit message, so + # multi-package release PRs and title typos are handled correctly. + melos run release:tag diff --git a/melos.yaml b/melos.yaml index 47541470..4e761189 100644 --- a/melos.yaml +++ b/melos.yaml @@ -120,6 +120,38 @@ scripts: Run `pub publish --dry-run` in all packages. - Note: you can also rely on your IDEs Dart Analysis / Issues window. + release:tag: + run: | + melos exec -c 1 --no-published --no-private --order-dependents -- \ + "git tag \$MELOS_PACKAGE_NAME-v\$MELOS_PACKAGE_VERSION || true" + melos exec -c 1 --no-published --no-private --order-dependents -- \ + "git push origin \$MELOS_PACKAGE_NAME-v\$MELOS_PACKAGE_VERSION" + description: | + Tag every publishable package whose current version is not yet on pub.dev + as `-v`, then push the tags β€” both in dependency order + (`--order-dependents`: dependencies first) so a dependent's publish is + triggered after its dependency's. `|| true`: an unchanged package may + already carry its tag. Used by the release_tag workflow after a merge. + + release:pub:dry: + run: | + melos exec -c 1 --no-published --no-private --order-dependents -- \ + "flutter pub publish --dry-run" + description: | + Dry-run publish of the unpublished packages, in dependency order. Set the + MELOS_PACKAGES env var to scope to one package (release_publish does this). + + release:pub: + run: | + melos exec -c 1 --no-published --no-private --order-dependents -- \ + "flutter pub publish --force" + description: | + Publish unpublished packages to pub.dev (via OIDC in CI) in dependency + order β€” a dependency is published before any package that depends on it. + Set MELOS_PACKAGES to scope to one package. Idempotent: `--no-published` + skips versions already on pub.dev, so re-runs are a clean no-op. Used by + the release_publish workflow. + generate:all: run: melos run generate:dart && melos run generate:flutter description: Build all generated files for Dart & Flutter packages in this project. From ad42bcefca184b431d96774677c80b4696c3d85c Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 30 Jul 2026 18:38:30 +0200 Subject: [PATCH 10/36] docs(repo): trim release workflow comments to essentials Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release_publish.yml | 66 +++++++++------------------ .github/workflows/release_tag.yml | 21 ++------- melos.yaml | 17 +++---- 3 files changed, 33 insertions(+), 71 deletions(-) diff --git a/.github/workflows/release_publish.yml b/.github/workflows/release_publish.yml index 666ef989..8d0ecd6c 100644 --- a/.github/workflows/release_publish.yml +++ b/.github/workflows/release_publish.yml @@ -3,21 +3,20 @@ name: release_publish on: push: tags: - - '*-v[0-9]+.[0-9]+.[0-9]+' # per-package release tags, e.g. stream_core-v0.4.0 - - '*-v[0-9]+.[0-9]+.[0-9]+-*' # per-package pre-release tags, e.g. stream_core-v0.4.0-beta.1 - workflow_dispatch: # Allow manual re-runs against an existing tag ref + - '*-v[0-9]+.[0-9]+.[0-9]+' # -vX.Y.Z + - '*-v[0-9]+.[0-9]+.[0-9]+-*' # -vX.Y.Z-
+  workflow_dispatch: # manual re-runs against a tag ref
 
 concurrency:
-  # Keyed on the tag ref. cancel-in-progress must stay false so a manual re-run
-  # does not cancel an in-flight publish of the same tag.
+  # 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 # Required to create the GitHub Release
-      id-token: write # Required for OIDC authentication to pub.dev
+      contents: write # create the GitHub Release
+      id-token: write # OIDC auth to pub.dev
     runs-on: ubuntu-latest
     steps:
       - name: πŸ“š Checkout branch
@@ -35,7 +34,6 @@ jobs:
           ref="${GITHUB_REF#refs/tags/}"
           echo "πŸ“¦ Tag: $ref"
 
-          # -v; version may carry a pre-release suffix.
           if [[ ! "$ref" =~ ^([a-z0-9_]+)-v(.+)$ ]]; then
             echo "::error ::Tag '$ref' does not match '-v'."
             exit 1
@@ -50,8 +48,7 @@ jobs:
             exit 1
           fi
 
-          # Guard against a stray tag: the pubspec version must equal the tag.
-          # pub.dev's OIDC check enforces this too, but failing here is clearer.
+          # 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)."
@@ -66,9 +63,8 @@ jobs:
             echo "prerelease=$is_prerelease"
           } >> "$GITHUB_OUTPUT"
 
-      # setup-dart provisions the OIDC token used to authenticate with pub.dev.
-      # It must run before flutter-action, which bundles Dart but not the OIDC setup.
       - name: 🎯 Setup Dart
+        # Before flutter-action: this provides the OIDC token for pub.dev.
         uses: dart-lang/setup-dart@v1
 
       - name: 🐦 Install Flutter
@@ -82,54 +78,43 @@ jobs:
       - name: πŸ”§ Bootstrap Workspace
         run: melos bootstrap --verbose
 
-      # melos publish only ever touches unpublished versions, so a re-run of an
-      # already-published tag is a clean no-op. MELOS_PACKAGES scopes the
-      # `release:pub*` scripts (melos.yaml) to just the tagged package.
       - name: 🌡 Dry Run
+        # MELOS_PACKAGES scopes release:pub* to just the tagged package.
         run: melos run release:pub:dry
         env:
           MELOS_PACKAGES: ${{ steps.parse.outputs.package }}
 
-      # Guarantee dependent-order publishing. Each package publishes in its own
-      # run (OIDC binds one tag ref β†’ one package), so when a release bumps both
-      # a package and something it depends on, the dependent's run can start
-      # before the dependency is live on pub.dev and its publish would fail
-      # resolution. Rather than lean on a manual re-run, wait here until every
-      # in-workspace dependency this package needs is live at the version the
-      # workspace pins. Common case (dependency already published, or none):
-      # returns immediately.
       - name: ⏳ Wait for in-workspace dependencies
+        # Each package publishes in its own OIDC run, so wait until this
+        # package's in-workspace deps are live before publishing β€” a dependent
+        # never publishes before its dependency. No-op when there are none.
         shell: bash
         run: |
           set -euo pipefail
 
           pkg="${{ steps.parse.outputs.package }}"
 
-          # This package's workspace dependencies, from melos's own dependency
-          # graph (`{package: [deps]}`). awk keeps only the JSON object, dropping
-          # any trailing log lines; it drains the whole stream (no early exit) so
-          # melos never takes a SIGPIPE that pipefail would turn into a failure.
+          # $pkg's workspace deps from melos's graph; awk keeps only the JSON
+          # object and drains the stream (no early exit -> no SIGPIPE).
           graph="$(melos list --graph 2>/dev/null | awk '/^\{/{f=1} f{print} /^\}$/{f=0}')"
           mapfile -t deps < <(printf '%s' "$graph" | jq -r --arg p "$pkg" '.[$p] // [] | .[]')
 
           if [[ "${#deps[@]}" -eq 0 ]]; then
-            echo "βœ… $pkg has no in-workspace dependencies; nothing to wait for."
+            echo "βœ… $pkg has no in-workspace dependencies."
             exit 0
           fi
 
           for dep in "${deps[@]}"; do
             dep_pubspec="packages/$dep/pubspec.yaml"
-            # A dependency outside packages/ (e.g. a private app) is never on
-            # pub.dev and cannot be a real dependency of a published package.
-            [[ -f "$dep_pubspec" ]] || continue
+            [[ -f "$dep_pubspec" ]] || continue # non-packages/ dep can't be on pub.dev
 
             want="$(grep -E '^version:' "$dep_pubspec" | head -n1 | sed -E 's/^version:[[:space:]]*//')"
-            echo "⏳ $pkg depends on $dep β€” waiting for $dep v$want on pub.dev…"
+            echo "⏳ Waiting for $dep v$want on pub.dev…"
 
             deadline=$((SECONDS + 900)) # 15 minutes
             until curl -sfL -o /dev/null "https://pub.dev/api/packages/$dep/versions/$want"; do
               if (( SECONDS >= deadline )); then
-                echo "::error ::Timed out waiting for $dep v$want on pub.dev. Once $dep is published, re-run this workflow (workflow_dispatch on this tag)."
+                echo "::error ::Timed out waiting for $dep v$want. Once it is published, re-run this workflow."
                 exit 1
               fi
               echo "  …not live yet; retrying in 15s"
@@ -154,10 +139,8 @@ jobs:
           changelog="packages/$pkg/CHANGELOG.md"
           notes_file="$RUNNER_TEMP/release_notes.md"
 
-          # Extract the `## ` section. Exact heading compare β€” versions
-          # carry dots and pre-release hyphens, so no regex. Not
-          # generate_release_notes: that would list every repo commit since the
-          # previous tag of *any* package.
+          # Exact `## ` heading match. Not generate_release_notes: it
+          # would list every package's commits since the previous tag.
           if [[ -f "$changelog" ]]; then
             awk -v version="$version" '
               $0 == "## " version { capture = 1; next }
@@ -166,8 +149,7 @@ jobs:
             ' "$changelog" > "$notes_file"
           fi
 
-          # Publishing already happened and is irreversible, so a missing or
-          # renamed heading must not fail the release β€” fall back to a link.
+          # 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}/packages/$pkg/CHANGELOG.md)." > "$notes_file"
           fi
@@ -181,9 +163,5 @@ jobs:
           name: ${{ steps.parse.outputs.package }} v${{ steps.parse.outputs.version }}
           body_path: ${{ steps.notes.outputs.path }}
           prerelease: ${{ steps.parse.outputs.prerelease }}
-          # Packages are versioned independently, so no single release is "the
-          # repo's latest" β€” a stream_thumbnail patch must not outrank a
-          # stream_core_flutter release for the Latest badge. Keep every
-          # per-package release off it.
-          make_latest: false
+          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
index 1955ceb3..04b5daa2 100644
--- a/.github/workflows/release_tag.yml
+++ b/.github/workflows/release_tag.yml
@@ -5,24 +5,20 @@ on:
     branches: [main]
 
 concurrency:
-  # Keyed on the branch ref, so back-to-back release merges share this group.
-  # cancel-in-progress must stay false: cancelling a run mid tag-push would
-  # silently drop a release.
+  # false: never cancel a run mid tag-push (would drop a release).
   group: ${{ github.workflow }}-${{ github.ref }}
   cancel-in-progress: false
 
 jobs:
   release:
-    # Run only for release commits. GitHub expressions have no regex, so the
-    # `chore(): release ...` prefix is matched with startsWith + contains.
+    # No regex in GH expressions; match the `chore(): release` prefix.
     if: "${{ startsWith(github.event.head_commit.message, 'chore(') && contains(github.event.head_commit.message, '): release') }}"
     runs-on: ubuntu-latest
     permissions:
-      contents: write # Required to create and push tags
+      contents: write
     steps:
       - name: πŸ“š Checkout branch
-        # Bot PAT (not the default GITHUB_TOKEN): tags pushed with GITHUB_TOKEN
-        # do not trigger the `on: push: tags` publish workflow.
+        # Bot PAT: GITHUB_TOKEN tag pushes don't trigger the publish workflow.
         uses: actions/checkout@v6
         with:
           fetch-depth: 0
@@ -34,20 +30,13 @@ jobs:
           channel: stable
 
       - name: πŸ“¦ Install Tools
-        # melos exec --no-published queries pub.dev, so melos must be installed.
-        # No bootstrap needed β€” package discovery does not require it.
         run: flutter pub global activate melos
 
       - name: 🏷️ Tag unpublished packages
+        # Tags derive from package state, not the commit message. See release:tag.
         shell: bash
         run: |
           set -euo pipefail
-
           git config user.name "Stream SDK Bot"
           git config user.email "60655709+Stream-SDK-Bot@users.noreply.github.com"
-
-          # Tag every package whose version is not yet on pub.dev and push the
-          # tags in dependency order. See the `release:tag` script in melos.yaml.
-          # Tags are derived from package state, not the commit message, so
-          # multi-package release PRs and title typos are handled correctly.
           melos run release:tag
diff --git a/melos.yaml b/melos.yaml
index 4e761189..08ef9fa3 100644
--- a/melos.yaml
+++ b/melos.yaml
@@ -127,29 +127,24 @@ scripts:
       melos exec -c 1 --no-published --no-private --order-dependents -- \
         "git push origin \$MELOS_PACKAGE_NAME-v\$MELOS_PACKAGE_VERSION"
     description: |
-      Tag every publishable package whose current version is not yet on pub.dev
-      as `-v`, then push the tags β€” both in dependency order
-      (`--order-dependents`: dependencies first) so a dependent's publish is
-      triggered after its dependency's. `|| true`: an unchanged package may
-      already carry its tag. Used by the release_tag workflow after a merge.
+      Tag unpublished packages (`-v`) and push, in dependency
+      order. Used by the release_tag workflow.
 
   release:pub:dry:
     run: |
       melos exec -c 1 --no-published --no-private --order-dependents -- \
         "flutter pub publish --dry-run"
     description: |
-      Dry-run publish of the unpublished packages, in dependency order. Set the
-      MELOS_PACKAGES env var to scope to one package (release_publish does this).
+      Dry-run publish of unpublished packages, in dependency order.
+      Set MELOS_PACKAGES to scope to one package.
 
   release:pub:
     run: |
       melos exec -c 1 --no-published --no-private --order-dependents -- \
         "flutter pub publish --force"
     description: |
-      Publish unpublished packages to pub.dev (via OIDC in CI) in dependency
-      order β€” a dependency is published before any package that depends on it.
-      Set MELOS_PACKAGES to scope to one package. Idempotent: `--no-published`
-      skips versions already on pub.dev, so re-runs are a clean no-op. Used by
+      Publish unpublished packages to pub.dev in dependency order (OIDC in CI).
+      Set MELOS_PACKAGES to scope to one package. Re-runs are a no-op. Used by
       the release_publish workflow.
 
   generate:all:

From 824c623114d30ebb6d4e4988248b10248534e6b3 Mon Sep 17 00:00:00 2001
From: Sahil Kumar 
Date: Thu, 30 Jul 2026 18:43:08 +0200
Subject: [PATCH 11/36] chore(repo): add release-pr skill

Port of stream-chat-flutter release-pr skill, adapted for per-package
independent versioning and hand-curated changelogs (no melos version).

Co-Authored-By: Claude Opus 4.8 (1M context) 
---
 .claude/skills/release-pr/SKILL.md | 160 +++++++++++++++++++++++++++++
 1 file changed, 160 insertions(+)
 create mode 100644 .claude/skills/release-pr/SKILL.md

diff --git a/.claude/skills/release-pr/SKILL.md b/.claude/skills/release-pr/SKILL.md
new file mode 100644
index 00000000..1c66d1fa
--- /dev/null
+++ b/.claude/skills/release-pr/SKILL.md
@@ -0,0 +1,160 @@
+---
+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 <...>` (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.
+
+Packages and their conventional-commit scopes:
+
+| Package | Path | Scope |
+|---|---|---|
+| `stream_core` | `packages/stream_core` | `llc` |
+| `stream_core_flutter` | `packages/stream_core_flutter` | `ui` |
+| `stream_thumbnail` | `packages/stream_thumbnail` | `thumb` |
+
+## 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 -uno` clean.
+- `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 <pkg1> vX.Y.Z, <pkg2> vA.B.C`.
+
+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.
+
+## 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`** (or `release:tag`) locally β€” those are the CI publish/tag steps; running them
+  publishes from an unreviewed tree. Refuse even if asked.
+- **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.

From d745aa354eb5153821a5e822488490ac3a184252 Mon Sep 17 00:00:00 2001
From: Sahil Kumar <sahil@getstream.io>
Date: Thu, 30 Jul 2026 18:45:37 +0200
Subject: [PATCH 12/36] refactor(repo): align pub scripts with
 stream-chat-flutter
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Repurpose the existing lint:pub to chat's exact pre-publish dry-run
command and make release:pub byte-identical (`-f`); drop the redundant
release:pub:dry. release_publish now runs `melos run lint:pub` for the
dry run. release:tag stays (no chat equivalent β€” per-package tagging).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .github/workflows/release_publish.yml |  4 ++--
 melos.yaml                            | 20 +++-----------------
 2 files changed, 5 insertions(+), 19 deletions(-)

diff --git a/.github/workflows/release_publish.yml b/.github/workflows/release_publish.yml
index 8d0ecd6c..c4128b95 100644
--- a/.github/workflows/release_publish.yml
+++ b/.github/workflows/release_publish.yml
@@ -79,8 +79,8 @@ jobs:
         run: melos bootstrap --verbose
 
       - name: 🌡 Dry Run
-        # MELOS_PACKAGES scopes release:pub* to just the tagged package.
-        run: melos run release:pub:dry
+        # MELOS_PACKAGES scopes lint:pub / release:pub to just the tagged package.
+        run: melos run lint:pub
         env:
           MELOS_PACKAGES: ${{ steps.parse.outputs.package }}
 
diff --git a/melos.yaml b/melos.yaml
index 08ef9fa3..c8f67a75 100644
--- a/melos.yaml
+++ b/melos.yaml
@@ -113,12 +113,8 @@ 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
-    description: |
-      Run `pub publish --dry-run` in all packages.
-       - Note: you can also rely on your IDEs Dart Analysis / Issues window.
+    run: melos exec -c 1 --no-published --no-private --order-dependents -- "flutter pub publish -n"
+    description: Dry run `pub publish` for unpublished packages, in dependency order.
 
   release:tag:
     run: |
@@ -130,18 +126,8 @@ scripts:
       Tag unpublished packages (`<package>-v<version>`) and push, in dependency
       order. Used by the release_tag workflow.
 
-  release:pub:dry:
-    run: |
-      melos exec -c 1 --no-published --no-private --order-dependents -- \
-        "flutter pub publish --dry-run"
-    description: |
-      Dry-run publish of unpublished packages, in dependency order.
-      Set MELOS_PACKAGES to scope to one package.
-
   release:pub:
-    run: |
-      melos exec -c 1 --no-published --no-private --order-dependents -- \
-        "flutter pub publish --force"
+    run: melos exec -c 1 --no-published --no-private --order-dependents -- "flutter pub publish -f"
     description: |
       Publish unpublished packages to pub.dev in dependency order (OIDC in CI).
       Set MELOS_PACKAGES to scope to one package. Re-runs are a no-op. Used by

From 6b622b1833107d177aba9ca7c0496211b6a221f5 Mon Sep 17 00:00:00 2001
From: Sahil Kumar <sahil@getstream.io>
Date: Thu, 30 Jul 2026 18:52:44 +0200
Subject: [PATCH 13/36] docs(repo): make multi-package release title generic;
 derive packages in skill

Multi-package release PRs use a generic `chore(repo): release packages` title
(short regardless of package count) instead of enumerating each pkg+version.
The release-pr skill now derives packages from `packages/*` and scopes from
pr_title.yml instead of a hard-coded table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .claude/skills/release-pr/SKILL.md | 16 +++++++++-------
 STYLE_GUIDE.md                     |  6 +++---
 2 files changed, 12 insertions(+), 10 deletions(-)

diff --git a/.claude/skills/release-pr/SKILL.md b/.claude/skills/release-pr/SKILL.md
index 1c66d1fa..6520bca9 100644
--- a/.claude/skills/release-pr/SKILL.md
+++ b/.claude/skills/release-pr/SKILL.md
@@ -39,13 +39,15 @@ See the "Releasing" section of `STYLE_GUIDE.md`.
 - **`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.
 
-Packages and their conventional-commit scopes:
+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:
 
-| Package | Path | Scope |
-|---|---|---|
-| `stream_core` | `packages/stream_core` | `llc` |
-| `stream_core_flutter` | `packages/stream_core_flutter` | `ui` |
-| `stream_thumbnail` | `packages/stream_thumbnail` | `thumb` |
+```bash
+grep -A6 'semantic_changelog_update' .github/workflows/pr_title.yml
+```
 
 ## Inputs
 
@@ -128,7 +130,7 @@ 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 <pkg1> vX.Y.Z, <pkg2> vA.B.C`.
+- 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.
diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md
index 0191ba57..fc2c7fc9 100644
--- a/STYLE_GUIDE.md
+++ b/STYLE_GUIDE.md
@@ -1391,9 +1391,9 @@ 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` for a multi-package release, 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
+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.
 

From 4991589dd880737f102c46e4d45bc40e20f111ca Mon Sep 17 00:00:00 2001
From: Sahil Kumar <sahil@getstream.io>
Date: Thu, 30 Jul 2026 22:46:10 +0200
Subject: [PATCH 14/36] refactor(repo): gate on melos --published instead of a
 raw pub.dev curl

The dependency gate now polls `melos list --published --scope=$dep`
(melos's own published detection, same as --no-published) instead of
curling the pub.dev API. Drops the hardcoded URL and the pubspec version
parsing; melos checks the workspace version directly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .github/workflows/release_publish.yml | 15 ++++++---------
 1 file changed, 6 insertions(+), 9 deletions(-)

diff --git a/.github/workflows/release_publish.yml b/.github/workflows/release_publish.yml
index c4128b95..beefc34c 100644
--- a/.github/workflows/release_publish.yml
+++ b/.github/workflows/release_publish.yml
@@ -105,22 +105,19 @@ jobs:
           fi
 
           for dep in "${deps[@]}"; do
-            dep_pubspec="packages/$dep/pubspec.yaml"
-            [[ -f "$dep_pubspec" ]] || continue # non-packages/ dep can't be on pub.dev
-
-            want="$(grep -E '^version:' "$dep_pubspec" | head -n1 | sed -E 's/^version:[[:space:]]*//')"
-            echo "⏳ Waiting for $dep v$want on pub.dev…"
-
+            echo "⏳ Waiting for $dep to be published on pub.dev…"
             deadline=$((SECONDS + 900)) # 15 minutes
-            until curl -sfL -o /dev/null "https://pub.dev/api/packages/$dep/versions/$want"; do
+            # melos's own published-check (same detection as --no-published):
+            # lists $dep only once its current version is live on pub.dev.
+            until melos list --published --scope="$dep" 2>/dev/null | grep -qx "$dep"; do
               if (( SECONDS >= deadline )); then
-                echo "::error ::Timed out waiting for $dep v$want. Once it is published, re-run this workflow."
+                echo "::error ::Timed out waiting for $dep on pub.dev. Once it is published, re-run this workflow."
                 exit 1
               fi
               echo "  …not live yet; retrying in 15s"
               sleep 15
             done
-            echo "βœ… $dep v$want is live."
+            echo "βœ… $dep is published."
           done
 
       - name: πŸ“’ Publish to pub.dev

From 26ad40c80225dca870a7fca10e2791f4dd5a364c Mon Sep 17 00:00:00 2001
From: Sahil Kumar <sahil@getstream.io>
Date: Thu, 30 Jul 2026 22:55:15 +0200
Subject: [PATCH 15/36] ci(repo): harden release_publish parsing and token
 exposure
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Addresses CodeRabbit/zizmor findings:
- Bound the tag version capture to a semver shape (was an open-ended `.+`).
- Pass parsed package/version to the gate and changelog steps via step-level
  env vars instead of interpolating ${{ }} into the run: scripts (avoids
  template injection; dry-run/publish already used env).
- persist-credentials: false on checkout β€” this job never pushes, so the bot
  token no longer sits in git config through the melos/pub steps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .github/workflows/release_publish.yml | 30 ++++++++++++++++-----------
 1 file changed, 18 insertions(+), 12 deletions(-)

diff --git a/.github/workflows/release_publish.yml b/.github/workflows/release_publish.yml
index beefc34c..3ba5d530 100644
--- a/.github/workflows/release_publish.yml
+++ b/.github/workflows/release_publish.yml
@@ -23,6 +23,9 @@ jobs:
         uses: actions/checkout@v6
         with:
           fetch-depth: 0
+          # This job never pushes (the Release step passes its token explicitly),
+          # so don't persist the token in git config through later third-party steps.
+          persist-credentials: false
           token: ${{ secrets.BOT_GITHUB_API_TOKEN }}
 
       - name: 🏷️ Parse package and version from tag
@@ -34,8 +37,10 @@ jobs:
           ref="${GITHUB_REF#refs/tags/}"
           echo "πŸ“¦ Tag: $ref"
 
-          if [[ ! "$ref" =~ ^([a-z0-9_]+)-v(.+)$ ]]; then
-            echo "::error ::Tag '$ref' does not match '<package>-v<version>'."
+          # <package>-v<semver>; version is bounded to a semver shape (with an
+          # optional pre-release suffix), not an open-ended capture.
+          if [[ ! "$ref" =~ ^([a-z0-9_]+)-v([0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?)$ ]]; then
+            echo "::error ::Tag '$ref' does not match '<package>-v<semver>'."
             exit 1
           fi
 
@@ -89,18 +94,18 @@ jobs:
         # package's in-workspace deps are live before publishing β€” a dependent
         # never publishes before its dependency. No-op when there are none.
         shell: bash
+        env:
+          PKG: ${{ steps.parse.outputs.package }}
         run: |
           set -euo pipefail
 
-          pkg="${{ steps.parse.outputs.package }}"
-
-          # $pkg's workspace deps from melos's graph; awk keeps only the JSON
+          # $PKG's workspace deps from melos's graph; awk keeps only the JSON
           # object and drains the stream (no early exit -> no SIGPIPE).
           graph="$(melos list --graph 2>/dev/null | awk '/^\{/{f=1} f{print} /^\}$/{f=0}')"
-          mapfile -t deps < <(printf '%s' "$graph" | jq -r --arg p "$pkg" '.[$p] // [] | .[]')
+          mapfile -t deps < <(printf '%s' "$graph" | jq -r --arg p "$PKG" '.[$p] // [] | .[]')
 
           if [[ "${#deps[@]}" -eq 0 ]]; then
-            echo "βœ… $pkg has no in-workspace dependencies."
+            echo "βœ… $PKG has no in-workspace dependencies."
             exit 0
           fi
 
@@ -128,18 +133,19 @@ jobs:
       - name: πŸ“ Extract CHANGELOG section
         id: notes
         shell: bash
+        env:
+          PKG: ${{ steps.parse.outputs.package }}
+          VERSION: ${{ steps.parse.outputs.version }}
         run: |
           set -euo pipefail
 
-          pkg="${{ steps.parse.outputs.package }}"
-          version="${{ steps.parse.outputs.version }}"
-          changelog="packages/$pkg/CHANGELOG.md"
+          changelog="packages/$PKG/CHANGELOG.md"
           notes_file="$RUNNER_TEMP/release_notes.md"
 
           # Exact `## <version>` heading match. Not generate_release_notes: it
           # would list every package's commits since the previous tag.
           if [[ -f "$changelog" ]]; then
-            awk -v version="$version" '
+            awk -v version="$VERSION" '
               $0 == "## " version { capture = 1; next }
               capture && /^## / { exit }
               capture { print }
@@ -148,7 +154,7 @@ jobs:
 
           # 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}/packages/$pkg/CHANGELOG.md)." > "$notes_file"
+            echo "See [CHANGELOG](https://github.com/${GITHUB_REPOSITORY}/blob/${GITHUB_REF_NAME}/packages/$PKG/CHANGELOG.md)." > "$notes_file"
           fi
 
           echo "path=$notes_file" >> "$GITHUB_OUTPUT"

From 416063bb7aef45d2ea3c66712c4be8c052a8ddd3 Mon Sep 17 00:00:00 2001
From: Sahil Kumar <sahil@getstream.io>
Date: Thu, 30 Jul 2026 22:56:23 +0200
Subject: [PATCH 16/36] ci(repo): use default GITHUB_TOKEN for release_publish
 checkout
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The checkout is read-only (only the Release step pushes, with its own
token), so drop the bot PAT from it entirely β€” the default GITHUB_TOKEN
clones fine. Combined with persist-credentials: false, the bot PAT never
enters this job.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .github/workflows/release_publish.yml | 7 ++++---
 1 file changed, 4 insertions(+), 3 deletions(-)

diff --git a/.github/workflows/release_publish.yml b/.github/workflows/release_publish.yml
index 3ba5d530..4bfa11a6 100644
--- a/.github/workflows/release_publish.yml
+++ b/.github/workflows/release_publish.yml
@@ -23,10 +23,11 @@ jobs:
         uses: actions/checkout@v6
         with:
           fetch-depth: 0
-          # This job never pushes (the Release step passes its token explicitly),
-          # so don't persist the token in git config through later third-party steps.
+          # Read-only checkout: this job never pushes (the Release step passes its
+          # own token), so use the default GITHUB_TOKEN and don't persist it in
+          # git config through the later melos/pub steps. The bot PAT never enters
+          # this job.
           persist-credentials: false
-          token: ${{ secrets.BOT_GITHUB_API_TOKEN }}
 
       - name: 🏷️ Parse package and version from tag
         id: parse

From cc93c5f2fa84e026fe0179121a7b13cb85767837 Mon Sep 17 00:00:00 2001
From: Sahil Kumar <sahil@getstream.io>
Date: Fri, 31 Jul 2026 00:15:18 +0200
Subject: [PATCH 17/36] ci(repo): match chat checkout in release_publish

Keep the bot PAT and default persist-credentials on the checkout to
match stream-chat-flutter, reverting the persist-credentials/token
change. The regex tightening and env-var scoping (no ${{ }} in run:)
stay.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .github/workflows/release_publish.yml | 6 +-----
 1 file changed, 1 insertion(+), 5 deletions(-)

diff --git a/.github/workflows/release_publish.yml b/.github/workflows/release_publish.yml
index 4bfa11a6..dc3679c1 100644
--- a/.github/workflows/release_publish.yml
+++ b/.github/workflows/release_publish.yml
@@ -23,11 +23,7 @@ jobs:
         uses: actions/checkout@v6
         with:
           fetch-depth: 0
-          # Read-only checkout: this job never pushes (the Release step passes its
-          # own token), so use the default GITHUB_TOKEN and don't persist it in
-          # git config through the later melos/pub steps. The bot PAT never enters
-          # this job.
-          persist-credentials: false
+          token: ${{ secrets.BOT_GITHUB_API_TOKEN }}
 
       - name: 🏷️ Parse package and version from tag
         id: parse

From 0cea5a018dac18aac843337f1fdd7e5710ac8c09 Mon Sep 17 00:00:00 2001
From: Sahil Kumar <sahil@getstream.io>
Date: Fri, 31 Jul 2026 02:06:44 +0200
Subject: [PATCH 18/36] fix(repo): make publish idempotent with a
 pub.dev-version guard

Before publishing, check the per-version pub.dev endpoint
(/api/packages/$PKG/versions/$VERSION) and skip the publish step if the
version is already live. melos --no-published (which release:pub uses)
reads the package listing, which lags for minutes after a publish, so
re-running a finished publish could wrongly retry and fail "already
exists". The version endpoint stays fresh, so re-runs are now a clean
no-op while the CHANGELOG/GitHub Release steps still run.

Validated end-to-end on a real per-package test repo: a fresh publish
proceeds; re-running a published tag skips publish and exits 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .github/workflows/release_publish.yml | 20 ++++++++++++++++++++
 1 file changed, 20 insertions(+)

diff --git a/.github/workflows/release_publish.yml b/.github/workflows/release_publish.yml
index dc3679c1..541a1fe3 100644
--- a/.github/workflows/release_publish.yml
+++ b/.github/workflows/release_publish.yml
@@ -122,7 +122,27 @@ jobs:
             echo "βœ… $dep is published."
           done
 
+      # Idempotency guard. melos's --no-published (which release:pub relies on)
+      # reads pub.dev's package listing, which can lag for minutes after a
+      # publish, so re-running a finished publish may wrongly retry and fail
+      # "already exists". The per-version endpoint stays fresh β€” gate on it.
+      - name: πŸ”Ž Skip publish if already on pub.dev
+        id: pubcheck
+        shell: bash
+        env:
+          PKG: ${{ steps.parse.outputs.package }}
+          VERSION: ${{ steps.parse.outputs.version }}
+        run: |
+          set -euo pipefail
+          if curl -sfL -o /dev/null "https://pub.dev/api/packages/$PKG/versions/$VERSION"; then
+            echo "published=true" >> "$GITHUB_OUTPUT"
+            echo "βœ… $PKG $VERSION is already on pub.dev β€” skipping publish (idempotent re-run)."
+          else
+            echo "published=false" >> "$GITHUB_OUTPUT"
+          fi
+
       - name: πŸ“’ Publish to pub.dev
+        if: steps.pubcheck.outputs.published == 'false'
         run: melos run release:pub
         env:
           MELOS_PACKAGES: ${{ steps.parse.outputs.package }}

From 72f832c8e3ff86f948cb907045ccb158f9a00e62 Mon Sep 17 00:00:00 2001
From: Sahil Kumar <sahil@getstream.io>
Date: Fri, 31 Jul 2026 02:27:43 +0200
Subject: [PATCH 19/36] revert: drop the pub.dev-version publish guard
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Re-runs of an already-published tag rely on melos --no-published (like
stream-chat-flutter and the melos-action ecosystem). The guard only
helped when manually re-running an already-succeeded publish β€” an
uncommon, self-inflicted case where the failure is safe (pub.dev rejects
the duplicate; nothing double-publishes). Keeping the workflow minimal
and matching the reference repos.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .github/workflows/release_publish.yml | 23 +++--------------------
 1 file changed, 3 insertions(+), 20 deletions(-)

diff --git a/.github/workflows/release_publish.yml b/.github/workflows/release_publish.yml
index 541a1fe3..fec109a9 100644
--- a/.github/workflows/release_publish.yml
+++ b/.github/workflows/release_publish.yml
@@ -122,27 +122,10 @@ jobs:
             echo "βœ… $dep is published."
           done
 
-      # Idempotency guard. melos's --no-published (which release:pub relies on)
-      # reads pub.dev's package listing, which can lag for minutes after a
-      # publish, so re-running a finished publish may wrongly retry and fail
-      # "already exists". The per-version endpoint stays fresh β€” gate on it.
-      - name: πŸ”Ž Skip publish if already on pub.dev
-        id: pubcheck
-        shell: bash
-        env:
-          PKG: ${{ steps.parse.outputs.package }}
-          VERSION: ${{ steps.parse.outputs.version }}
-        run: |
-          set -euo pipefail
-          if curl -sfL -o /dev/null "https://pub.dev/api/packages/$PKG/versions/$VERSION"; then
-            echo "published=true" >> "$GITHUB_OUTPUT"
-            echo "βœ… $PKG $VERSION is already on pub.dev β€” skipping publish (idempotent re-run)."
-          else
-            echo "published=false" >> "$GITHUB_OUTPUT"
-          fi
-
+      # melos publish only ever touches unpublished versions (--no-published),
+      # so a re-run of an already-published tag is a no-op. Scoped to the tagged
+      # package via MELOS_PACKAGES.
       - name: πŸ“’ Publish to pub.dev
-        if: steps.pubcheck.outputs.published == 'false'
         run: melos run release:pub
         env:
           MELOS_PACKAGES: ${{ steps.parse.outputs.package }}

From 3d512d4a5f774231757d25ae62e38371fd681664 Mon Sep 17 00:00:00 2001
From: Sahil Kumar <sahil@getstream.io>
Date: Fri, 31 Jul 2026 03:28:03 +0200
Subject: [PATCH 20/36] refactor(repo): drop the dependency gate for pub
 publish --skip-validation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Replace the bespoke "wait until in-workspace deps are on pub.dev" gate
step with `flutter pub publish --skip-validation` on the publish command
β€” Dart's recommended approach for publishing interdependent packages.
A package no longer blocks on its dependency being indexed; the
dependent resolves once the dependency's own run lands moments later.

Removes the melos --graph/awk/jq parsing and the melos --published poll,
whose CI listing-lag made coordinated releases slow (~75s+/level) and
brittle. Validated end-to-end on a per-package test repo: chain and
fan-out releases publish immediately with no failures.

Tradeoff: if a dependency's own publish fails, the dependent is
momentarily unresolvable until the dependency is re-published (safe,
idempotent re-run). Documented in STYLE_GUIDE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .github/workflows/release_publish.yml | 46 ++++-----------------------
 STYLE_GUIDE.md                        | 22 +++++++------
 melos.yaml                            | 10 +++---
 3 files changed, 25 insertions(+), 53 deletions(-)

diff --git a/.github/workflows/release_publish.yml b/.github/workflows/release_publish.yml
index fec109a9..f7d43b89 100644
--- a/.github/workflows/release_publish.yml
+++ b/.github/workflows/release_publish.yml
@@ -86,45 +86,13 @@ jobs:
         env:
           MELOS_PACKAGES: ${{ steps.parse.outputs.package }}
 
-      - name: ⏳ Wait for in-workspace dependencies
-        # Each package publishes in its own OIDC run, so wait until this
-        # package's in-workspace deps are live before publishing β€” a dependent
-        # never publishes before its dependency. No-op when there are none.
-        shell: bash
-        env:
-          PKG: ${{ steps.parse.outputs.package }}
-        run: |
-          set -euo pipefail
-
-          # $PKG's workspace deps from melos's graph; awk keeps only the JSON
-          # object and drains the stream (no early exit -> no SIGPIPE).
-          graph="$(melos list --graph 2>/dev/null | awk '/^\{/{f=1} f{print} /^\}$/{f=0}')"
-          mapfile -t deps < <(printf '%s' "$graph" | jq -r --arg p "$PKG" '.[$p] // [] | .[]')
-
-          if [[ "${#deps[@]}" -eq 0 ]]; then
-            echo "βœ… $PKG has no in-workspace dependencies."
-            exit 0
-          fi
-
-          for dep in "${deps[@]}"; do
-            echo "⏳ Waiting for $dep to be published on pub.dev…"
-            deadline=$((SECONDS + 900)) # 15 minutes
-            # melos's own published-check (same detection as --no-published):
-            # lists $dep only once its current version is live on pub.dev.
-            until melos list --published --scope="$dep" 2>/dev/null | grep -qx "$dep"; do
-              if (( SECONDS >= deadline )); then
-                echo "::error ::Timed out waiting for $dep on pub.dev. Once it is published, re-run this workflow."
-                exit 1
-              fi
-              echo "  …not live yet; retrying in 15s"
-              sleep 15
-            done
-            echo "βœ… $dep is published."
-          done
-
-      # melos publish only ever touches unpublished versions (--no-published),
-      # so a re-run of an already-published tag is a no-op. Scoped to the tagged
-      # package via MELOS_PACKAGES.
+      # Each package publishes in its own OIDC run, so when a release bumps a
+      # package and something it depends on, the dependent can reach the server
+      # before the dependency is indexed. release:pub uses `--skip-validation`
+      # (Dart's recommended approach for interdependent packages) so publishing
+      # doesn't block on the dependency being live β€” the dependent resolves once
+      # its dependency's own run lands moments later. --no-published still makes
+      # a re-run of an already-published tag a no-op.
       - name: πŸ“’ Publish to pub.dev
         run: melos run release:pub
         env:
diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md
index fc2c7fc9..cc29189e 100644
--- a/STYLE_GUIDE.md
+++ b/STYLE_GUIDE.md
@@ -1407,16 +1407,18 @@ When the PR merges to `main`:
    then creates a GitHub Release whose body is the package's `## X.Y.Z` CHANGELOG
    section.
 
-**Dependent order is automatic.** `stream_core_flutter` depends on `stream_core`,
-and each package publishes in its own run, so releasing both together could
-otherwise let the dependent publish before its dependency is on pub.dev.
-`release_publish.yml` prevents this: before publishing, it waits until every
-in-workspace dependency it needs is live on pub.dev at the pinned version (and
-`release_tag.yml` pushes tags dependency-first to keep that wait short). No
-manual step is needed. If a dependency's own publish genuinely fails, the
-dependent times out after 15 minutes; fix the dependency, then re-run the
-dependent's workflow (`workflow_dispatch` on its tag) β€” publishing is idempotent,
-so re-runs are safe.
+**Dependent order needs no coordination.** `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`).
+`release:pub` publishes with `flutter pub publish --skip-validation` β€” Dart's
+[recommended approach](https://dart.dev/tools/pub/cmd/pub-lish) for publishing
+interdependent packages β€” so a package doesn't block on its dependency being
+live; the dependent resolves as soon as the dependency's own run lands moments
+later. No wait, no manual step. Caveat: if a dependency's own publish genuinely
+*fails*, the dependent is published momentarily unresolvable until you re-run
+the failed dependency (`workflow_dispatch` on its tag) β€” publishing is
+idempotent, so that re-run is safe.
 
 
 ## Where to look when you're stuck
diff --git a/melos.yaml b/melos.yaml
index c8f67a75..c1a8faf2 100644
--- a/melos.yaml
+++ b/melos.yaml
@@ -127,11 +127,13 @@ scripts:
       order. Used by the release_tag workflow.
 
   release:pub:
-    run: melos exec -c 1 --no-published --no-private --order-dependents -- "flutter pub publish -f"
+    run: melos exec -c 1 --no-published --no-private --order-dependents -- "flutter pub publish -f --skip-validation"
     description: |
-      Publish unpublished packages to pub.dev in dependency order (OIDC in CI).
-      Set MELOS_PACKAGES to scope to one package. Re-runs are a no-op. Used by
-      the release_publish workflow.
+      Publish unpublished packages to pub.dev (OIDC in CI). `--skip-validation`
+      lets a package publish before an in-workspace dependency it needs is
+      indexed (Dart's approach for interdependent packages); the dependent
+      resolves once the dependency's own run lands. Set MELOS_PACKAGES to scope
+      to one package. Re-runs are a no-op. Used by the release_publish workflow.
 
   generate:all:
     run: melos run generate:dart && melos run generate:flutter

From 67283e231d24f9693e9dcc14ce1f6bb1487cfa8b Mon Sep 17 00:00:00 2001
From: Sahil Kumar <sahil@getstream.io>
Date: Fri, 31 Jul 2026 03:39:01 +0200
Subject: [PATCH 21/36] refactor(repo): harden changelog extraction (melos path
 + token match)

Align the release-notes step with bluefireteam/melos-action:
- resolve the package dir via `melos list --json` instead of assuming
  `packages/<name>` (drops the dir==name assumption).
- match the `## <version>` heading by version token ($2==ver), so a
  dated heading like `## 0.4.0 (2026-07-31)` is still found.
- append a "Published to pub.dev: <link>" footer to the release body.

Verified on the test repo: a dated-header release extracts correctly and
links to the published version.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .github/workflows/release_publish.yml | 25 +++++++++++++++++--------
 1 file changed, 17 insertions(+), 8 deletions(-)

diff --git a/.github/workflows/release_publish.yml b/.github/workflows/release_publish.yml
index f7d43b89..92b000b3 100644
--- a/.github/workflows/release_publish.yml
+++ b/.github/workflows/release_publish.yml
@@ -107,24 +107,33 @@ jobs:
         run: |
           set -euo pipefail
 
-          changelog="packages/$PKG/CHANGELOG.md"
+          # Resolve the package's location from melos rather than assuming the
+          # directory name matches the 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"
 
-          # Exact `## <version>` heading match. Not generate_release_notes: it
+          # Extract the `## <version>` section by matching the version *token*
+          # ($2), so a dated heading like `## 0.4.0 (2026-07-31)` still matches
+          # and "0.4.0" never matches "0.4.00". Not generate_release_notes: that
           # would list every package's commits since the previous tag.
           if [[ -f "$changelog" ]]; then
-            awk -v version="$VERSION" '
-              $0 == "## " version { capture = 1; next }
-              capture && /^## / { exit }
-              capture { print }
-            ' "$changelog" > "$notes_file"
+            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}/packages/$PKG/CHANGELOG.md)." > "$notes_file"
+            echo "See [CHANGELOG](https://github.com/${GITHUB_REPOSITORY}/blob/${GITHUB_REF_NAME}/$rel_path/CHANGELOG.md)." > "$notes_file"
           fi
 
+          # Footer linking to the published version.
+          {
+            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

From f1daa5d03e7ee116f0f22c209c94ef09e1dff49d Mon Sep 17 00:00:00 2001
From: Sahil Kumar <sahil@getstream.io>
Date: Fri, 31 Jul 2026 03:53:47 +0200
Subject: [PATCH 22/36] refactor(repo): restore dependency wait instead of
 --skip-validation

Publishing interdependent packages with --skip-validation would let a
dependent publish before its dependency is live on pub.dev, leaving it
momentarily unresolvable if the dependency's own run fails. Restore the
"Wait for in-workspace dependencies" step: it polls pub.dev's fresh
per-version endpoint until each dependency is live before publishing, so
a dependent never publishes against a missing dependency. release:pub
drops --skip-validation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .github/workflows/release_publish.yml | 53 ++++++++++++++++++---------
 STYLE_GUIDE.md                        | 20 +++++-----
 melos.yaml                            | 11 +++---
 3 files changed, 51 insertions(+), 33 deletions(-)

diff --git a/.github/workflows/release_publish.yml b/.github/workflows/release_publish.yml
index 92b000b3..df83bb53 100644
--- a/.github/workflows/release_publish.yml
+++ b/.github/workflows/release_publish.yml
@@ -34,8 +34,7 @@ jobs:
           ref="${GITHUB_REF#refs/tags/}"
           echo "πŸ“¦ Tag: $ref"
 
-          # <package>-v<semver>; version is bounded to a semver shape (with an
-          # optional pre-release suffix), not an open-ended capture.
+          # <package>-v<semver>, with an optional pre-release suffix.
           if [[ ! "$ref" =~ ^([a-z0-9_]+)-v([0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?)$ ]]; then
             echo "::error ::Tag '$ref' does not match '<package>-v<semver>'."
             exit 1
@@ -66,7 +65,7 @@ jobs:
           } >> "$GITHUB_OUTPUT"
 
       - name: 🎯 Setup Dart
-        # Before flutter-action: this provides the OIDC token for pub.dev.
+        # Before Flutter: provides the pub.dev OIDC token.
         uses: dart-lang/setup-dart@v1
 
       - name: 🐦 Install Flutter
@@ -86,13 +85,37 @@ jobs:
         env:
           MELOS_PACKAGES: ${{ steps.parse.outputs.package }}
 
-      # Each package publishes in its own OIDC run, so when a release bumps a
-      # package and something it depends on, the dependent can reach the server
-      # before the dependency is indexed. release:pub uses `--skip-validation`
-      # (Dart's recommended approach for interdependent packages) so publishing
-      # doesn't block on the dependency being live β€” the dependent resolves once
-      # its dependency's own run lands moments later. --no-published still makes
-      # a re-run of an already-published tag a no-op.
+      - name: ⏳ Wait for in-workspace dependencies
+        # A dependent can reach pub.dev before its dependency is indexed (each
+        # publishes in its own OIDC run), which the server rejects. Wait until
+        # each in-workspace dependency is live β€” 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
+
+          # Dep names from the graph; versions from list --json (dir name may differ).
+          info="$(melos list --json 2>/dev/null)"
+          deps="$(melos list --graph 2>/dev/null | awk '/^\{/{f=1} f{print} /^\}$/{f=0}' \
+            | jq -r --arg p "$PKG" '.[$p] // [] | .[]')"
+
+          [[ -z "$deps" ]] && { echo "βœ… $PKG has no in-workspace dependencies."; exit 0; }
+
+          for dep in $deps; do
+            want="$(printf '%s' "$info" | jq -r --arg n "$dep" '.[] | select(.name==$n) | .version')"
+            echo "⏳ Waiting for $dep v$want on pub.dev…"
+            deadline=$((SECONDS + 900)) # 15 minutes
+            until curl -sfL -o /dev/null "https://pub.dev/api/packages/$dep/versions/$want"; do
+              (( SECONDS < deadline )) || { echo "::error ::Timed out waiting for $dep v$want. Re-run once it's published."; exit 1; }
+              echo "  …not live yet; retrying in 15s"
+              sleep 15
+            done
+            echo "βœ… $dep v$want is live."
+          done
+
+      # --no-published makes a re-run of an already-published tag a no-op.
       - name: πŸ“’ Publish to pub.dev
         run: melos run release:pub
         env:
@@ -107,17 +130,14 @@ jobs:
         run: |
           set -euo pipefail
 
-          # Resolve the package's location from melos rather than assuming the
-          # directory name matches the package name.
+          # 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"
 
-          # Extract the `## <version>` section by matching the version *token*
-          # ($2), so a dated heading like `## 0.4.0 (2026-07-31)` still matches
-          # and "0.4.0" never matches "0.4.00". Not generate_release_notes: that
-          # would list every package's commits since the previous tag.
+          # 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
@@ -127,7 +147,6 @@ jobs:
             echo "See [CHANGELOG](https://github.com/${GITHUB_REPOSITORY}/blob/${GITHUB_REF_NAME}/$rel_path/CHANGELOG.md)." > "$notes_file"
           fi
 
-          # Footer linking to the published version.
           {
             echo ""
             echo "---"
diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md
index cc29189e..aa31ec47 100644
--- a/STYLE_GUIDE.md
+++ b/STYLE_GUIDE.md
@@ -1407,18 +1407,18 @@ When the PR merges to `main`:
    then creates a GitHub Release whose body is the package's `## X.Y.Z` CHANGELOG
    section.
 
-**Dependent order needs no coordination.** `stream_core_flutter` depends on
+**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`).
-`release:pub` publishes with `flutter pub publish --skip-validation` β€” Dart's
-[recommended approach](https://dart.dev/tools/pub/cmd/pub-lish) for publishing
-interdependent packages β€” so a package doesn't block on its dependency being
-live; the dependent resolves as soon as the dependency's own run lands moments
-later. No wait, no manual step. Caveat: if a dependency's own publish genuinely
-*fails*, the dependent is published momentarily unresolvable until you re-run
-the failed dependency (`workflow_dispatch` on its tag) β€” publishing is
-idempotent, so that re-run is safe.
+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 is usually a single poll. 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; publishing is idempotent,
+so re-runs are safe.
 
 
 ## Where to look when you're stuck
diff --git a/melos.yaml b/melos.yaml
index c1a8faf2..ea5334ba 100644
--- a/melos.yaml
+++ b/melos.yaml
@@ -127,13 +127,12 @@ scripts:
       order. Used by the release_tag workflow.
 
   release:pub:
-    run: melos exec -c 1 --no-published --no-private --order-dependents -- "flutter pub publish -f --skip-validation"
+    run: melos exec -c 1 --no-published --no-private --order-dependents -- "flutter pub publish -f"
     description: |
-      Publish unpublished packages to pub.dev (OIDC in CI). `--skip-validation`
-      lets a package publish before an in-workspace dependency it needs is
-      indexed (Dart's approach for interdependent packages); the dependent
-      resolves once the dependency's own run lands. Set MELOS_PACKAGES to scope
-      to one package. Re-runs are a no-op. Used by the release_publish workflow.
+      Publish unpublished packages to pub.dev (OIDC in CI). Set MELOS_PACKAGES
+      to scope to one package. Re-runs are a no-op (`--no-published`). Used by
+      the release_publish workflow, which first waits for in-workspace
+      dependencies to be live so publish never fails on a missing dependency.
 
   generate:all:
     run: melos run generate:dart && melos run generate:flutter

From 3cfddec7a76f49cb6f41cfe7e21576edde0d7242 Mon Sep 17 00:00:00 2001
From: Sahil Kumar <sahil@getstream.io>
Date: Fri, 31 Jul 2026 03:57:20 +0200
Subject: [PATCH 23/36] docs(repo): drop redundant --no-published comment

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .github/workflows/release_publish.yml | 1 -
 1 file changed, 1 deletion(-)

diff --git a/.github/workflows/release_publish.yml b/.github/workflows/release_publish.yml
index df83bb53..fe15c687 100644
--- a/.github/workflows/release_publish.yml
+++ b/.github/workflows/release_publish.yml
@@ -115,7 +115,6 @@ jobs:
             echo "βœ… $dep v$want is live."
           done
 
-      # --no-published makes a re-run of an already-published tag a no-op.
       - name: πŸ“’ Publish to pub.dev
         run: melos run release:pub
         env:

From 1f9da35fc098e25248b7b0c4d549f21b6f605f4d Mon Sep 17 00:00:00 2001
From: Sahil Kumar <sahil@getstream.io>
Date: Fri, 31 Jul 2026 04:16:07 +0200
Subject: [PATCH 24/36] ci(repo): support build-metadata tags; catch untracked
 files in release skill
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Address CodeRabbit review on #142:

- release_publish.yml: align the tag trigger with pub.dev's suggested OIDC
  pattern (`<pkg>-vX.Y.Z*`) and extend the parse regex to accept build
  metadata (`+…`) alongside pre-release suffixes. A `+build` release now
  fires the workflow and validates, rather than being silently skipped. Make
  pre-release detection ignore the build-metadata part.
- release-pr skill: pre-flight now requires `git status --short` clean
  *including untracked files*, so a stray local file can't enter the release
  commit via `git add -A`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .claude/skills/release-pr/SKILL.md    |  3 ++-
 .github/workflows/release_publish.yml | 12 +++++++-----
 2 files changed, 9 insertions(+), 6 deletions(-)

diff --git a/.claude/skills/release-pr/SKILL.md b/.claude/skills/release-pr/SKILL.md
index 6520bca9..d4a14732 100644
--- a/.claude/skills/release-pr/SKILL.md
+++ b/.claude/skills/release-pr/SKILL.md
@@ -62,7 +62,8 @@ grep -A6 'semantic_changelog_update' .github/workflows/pr_title.yml
 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 -uno` clean.
+- `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 <branch> --state all --json number` returns `[]`.
diff --git a/.github/workflows/release_publish.yml b/.github/workflows/release_publish.yml
index fe15c687..955682c0 100644
--- a/.github/workflows/release_publish.yml
+++ b/.github/workflows/release_publish.yml
@@ -3,8 +3,9 @@ name: release_publish
 on:
   push:
     tags:
-      - '*-v[0-9]+.[0-9]+.[0-9]+'   # <pkg>-vX.Y.Z
-      - '*-v[0-9]+.[0-9]+.[0-9]+-*' # <pkg>-vX.Y.Z-<pre>
+      # <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:
@@ -34,8 +35,8 @@ jobs:
           ref="${GITHUB_REF#refs/tags/}"
           echo "πŸ“¦ Tag: $ref"
 
-          # <package>-v<semver>, with an optional pre-release suffix.
-          if [[ ! "$ref" =~ ^([a-z0-9_]+)-v([0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?)$ ]]; then
+          # <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
@@ -56,7 +57,8 @@ jobs:
             exit 1
           fi
 
-          is_prerelease=$([[ "$version" == *-* ]] && echo true || echo false)
+          # Pre-release = a hyphen suffix, ignoring any build-metadata (+…) part.
+          is_prerelease=$([[ "${version%%+*}" == *-* ]] && echo true || echo false)
 
           {
             echo "package=$pkg"

From d404903f8cabc86a457f4a0161e7e7d4ce507766 Mon Sep 17 00:00:00 2001
From: Sahil Kumar <sahil@getstream.io>
Date: Fri, 31 Jul 2026 04:22:13 +0200
Subject: [PATCH 25/36] ci(repo): bound pub.dev probes and guard stale release
 tags
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Address CodeRabbit review on #142:

- release_publish.yml: add --connect-timeout/--max-time to the dependency
  wait probe so a stalled transfer can't hang past the 15-minute deadline.
- release:tag: replace `git tag … || true` with tag_package.sh, which creates
  the tag at HEAD, no-ops if it already points at HEAD, and fails loudly if an
  existing tag points at a different commit (a stale/failed release) instead of
  silently re-pushing the wrong tree or skipping the publish.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .github/workflows/release_publish.yml    |  2 +-
 .github/workflows/scripts/tag_package.sh | 22 ++++++++++++++++++++++
 melos.yaml                               |  2 +-
 3 files changed, 24 insertions(+), 2 deletions(-)
 create mode 100755 .github/workflows/scripts/tag_package.sh

diff --git a/.github/workflows/release_publish.yml b/.github/workflows/release_publish.yml
index 955682c0..daf5212f 100644
--- a/.github/workflows/release_publish.yml
+++ b/.github/workflows/release_publish.yml
@@ -109,7 +109,7 @@ jobs:
             want="$(printf '%s' "$info" | jq -r --arg n "$dep" '.[] | select(.name==$n) | .version')"
             echo "⏳ Waiting for $dep v$want on pub.dev…"
             deadline=$((SECONDS + 900)) # 15 minutes
-            until curl -sfL -o /dev/null "https://pub.dev/api/packages/$dep/versions/$want"; do
+            until curl -sfL --connect-timeout 10 --max-time 30 -o /dev/null "https://pub.dev/api/packages/$dep/versions/$want"; do
               (( SECONDS < deadline )) || { echo "::error ::Timed out waiting for $dep v$want. Re-run once it's published."; exit 1; }
               echo "  …not live yet; retrying in 15s"
               sleep 15
diff --git a/.github/workflows/scripts/tag_package.sh b/.github/workflows/scripts/tag_package.sh
new file mode 100755
index 00000000..b4550cf9
--- /dev/null
+++ b/.github/workflows/scripts/tag_package.sh
@@ -0,0 +1,22 @@
+#!/bin/bash
+
+# Create a package's release tag (<pkg>-v<version>) at HEAD, idempotently.
+# Invoked by `melos run release:tag` via `melos exec`, once per unpublished
+# package β€” MELOS_PACKAGE_NAME / MELOS_PACKAGE_VERSION come from melos.
+set -euo pipefail
+
+tag="$MELOS_PACKAGE_NAME-v$MELOS_PACKAGE_VERSION"
+
+if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then
+  # Tag already exists (e.g. a re-run). Fine only if it points at HEAD; a tag
+  # on a different commit is a stale/failed release β€” stop loudly rather than
+  # silently re-pushing the wrong tree (or not publishing at all).
+  if [ "$(git rev-parse "$tag^{commit}")" != "$(git rev-parse "HEAD^{commit}")" ]; then
+    echo "::error ::Tag $tag already exists at a different commit than HEAD; resolve it before releasing."
+    exit 1
+  fi
+  echo "βœ… $tag already exists at HEAD; nothing to do."
+else
+  git tag "$tag"
+  echo "🏷️ Created $tag."
+fi
diff --git a/melos.yaml b/melos.yaml
index ea5334ba..7f736c07 100644
--- a/melos.yaml
+++ b/melos.yaml
@@ -119,7 +119,7 @@ scripts:
   release:tag:
     run: |
       melos exec -c 1 --no-published --no-private --order-dependents -- \
-        "git tag \$MELOS_PACKAGE_NAME-v\$MELOS_PACKAGE_VERSION || true"
+        "\$MELOS_ROOT_PATH/.github/workflows/scripts/tag_package.sh"
       melos exec -c 1 --no-published --no-private --order-dependents -- \
         "git push origin \$MELOS_PACKAGE_NAME-v\$MELOS_PACKAGE_VERSION"
     description: |

From b385c50f3e41584e6cf148aa5aabec69593f7b0d Mon Sep 17 00:00:00 2001
From: Sahil Kumar <sahil@getstream.io>
Date: Fri, 31 Jul 2026 04:26:59 +0200
Subject: [PATCH 26/36] refactor(repo): tag and push in one melos exec pass
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Collapse release:tag's two exec passes into one that tags (via tag_package.sh)
then pushes each package in dependency order β€” matching chat's tag-then-push
shape. The stale-tag guard still runs first, and `&&` skips the push if it
fails, so a bad tag never gets pushed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 melos.yaml | 4 +---
 1 file changed, 1 insertion(+), 3 deletions(-)

diff --git a/melos.yaml b/melos.yaml
index 7f736c07..1a9dc8e3 100644
--- a/melos.yaml
+++ b/melos.yaml
@@ -119,9 +119,7 @@ scripts:
   release:tag:
     run: |
       melos exec -c 1 --no-published --no-private --order-dependents -- \
-        "\$MELOS_ROOT_PATH/.github/workflows/scripts/tag_package.sh"
-      melos exec -c 1 --no-published --no-private --order-dependents -- \
-        "git push origin \$MELOS_PACKAGE_NAME-v\$MELOS_PACKAGE_VERSION"
+        "\$MELOS_ROOT_PATH/.github/workflows/scripts/tag_package.sh && git push origin \$MELOS_PACKAGE_NAME-v\$MELOS_PACKAGE_VERSION"
     description: |
       Tag unpublished packages (`<package>-v<version>`) and push, in dependency
       order. Used by the release_tag workflow.

From 77c8d843cefb6f2ed04bbddf7dd03aec26a8a81c Mon Sep 17 00:00:00 2001
From: Sahil Kumar <sahil@getstream.io>
Date: Fri, 31 Jul 2026 04:27:43 +0200
Subject: [PATCH 27/36] refactor(repo): push the tag inside tag_package.sh

Move `git push` into the script so release:tag is a single script call per
package. The push is idempotent (no-op if origin already has the tag) and is
skipped when the stale-tag guard fails.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .github/workflows/scripts/tag_package.sh | 7 +++++--
 melos.yaml                               | 2 +-
 2 files changed, 6 insertions(+), 3 deletions(-)

diff --git a/.github/workflows/scripts/tag_package.sh b/.github/workflows/scripts/tag_package.sh
index b4550cf9..d94f57fb 100755
--- a/.github/workflows/scripts/tag_package.sh
+++ b/.github/workflows/scripts/tag_package.sh
@@ -1,6 +1,6 @@
 #!/bin/bash
 
-# Create a package's release tag (<pkg>-v<version>) at HEAD, idempotently.
+# Tag a package's release (<pkg>-v<version>) at HEAD and push it, idempotently.
 # Invoked by `melos run release:tag` via `melos exec`, once per unpublished
 # package β€” MELOS_PACKAGE_NAME / MELOS_PACKAGE_VERSION come from melos.
 set -euo pipefail
@@ -15,8 +15,11 @@ if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then
     echo "::error ::Tag $tag already exists at a different commit than HEAD; resolve it before releasing."
     exit 1
   fi
-  echo "βœ… $tag already exists at HEAD; nothing to do."
+  echo "βœ… $tag already exists at HEAD."
 else
   git tag "$tag"
   echo "🏷️ Created $tag."
 fi
+
+# Idempotent: a no-op if origin already has the tag.
+git push origin "$tag"
diff --git a/melos.yaml b/melos.yaml
index 1a9dc8e3..b2fc2a82 100644
--- a/melos.yaml
+++ b/melos.yaml
@@ -119,7 +119,7 @@ scripts:
   release:tag:
     run: |
       melos exec -c 1 --no-published --no-private --order-dependents -- \
-        "\$MELOS_ROOT_PATH/.github/workflows/scripts/tag_package.sh && git push origin \$MELOS_PACKAGE_NAME-v\$MELOS_PACKAGE_VERSION"
+        "\$MELOS_ROOT_PATH/.github/workflows/scripts/tag_package.sh"
     description: |
       Tag unpublished packages (`<package>-v<version>`) and push, in dependency
       order. Used by the release_tag workflow.

From 015a791b91a72cd26bd6f3efa04303a5422e19f3 Mon Sep 17 00:00:00 2001
From: Sahil Kumar <sahil@getstream.io>
Date: Fri, 31 Jul 2026 04:42:06 +0200
Subject: [PATCH 28/36] refactor(repo): push only HEAD tags instead of guarding
 stale tags
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Replace tag_package.sh with melos-action's proven pattern: create tags with
`git tag … || true`, then push only tags that point at HEAD, one at a time.
Pushing only HEAD tags means a stale tag on an old commit (e.g. a prior release
that was tagged but never published) is simply ignored rather than failing the
whole run β€” no stop-the-line, while still emitting one tag event per package.
The publish-side dependency wait already handles ordering, so pushes need no
special order.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .github/workflows/scripts/tag_package.sh | 25 ------------------------
 melos.yaml                               | 10 +++++++---
 2 files changed, 7 insertions(+), 28 deletions(-)
 delete mode 100755 .github/workflows/scripts/tag_package.sh

diff --git a/.github/workflows/scripts/tag_package.sh b/.github/workflows/scripts/tag_package.sh
deleted file mode 100755
index d94f57fb..00000000
--- a/.github/workflows/scripts/tag_package.sh
+++ /dev/null
@@ -1,25 +0,0 @@
-#!/bin/bash
-
-# Tag a package's release (<pkg>-v<version>) at HEAD and push it, idempotently.
-# Invoked by `melos run release:tag` via `melos exec`, once per unpublished
-# package β€” MELOS_PACKAGE_NAME / MELOS_PACKAGE_VERSION come from melos.
-set -euo pipefail
-
-tag="$MELOS_PACKAGE_NAME-v$MELOS_PACKAGE_VERSION"
-
-if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then
-  # Tag already exists (e.g. a re-run). Fine only if it points at HEAD; a tag
-  # on a different commit is a stale/failed release β€” stop loudly rather than
-  # silently re-pushing the wrong tree (or not publishing at all).
-  if [ "$(git rev-parse "$tag^{commit}")" != "$(git rev-parse "HEAD^{commit}")" ]; then
-    echo "::error ::Tag $tag already exists at a different commit than HEAD; resolve it before releasing."
-    exit 1
-  fi
-  echo "βœ… $tag already exists at HEAD."
-else
-  git tag "$tag"
-  echo "🏷️ Created $tag."
-fi
-
-# Idempotent: a no-op if origin already has the tag.
-git push origin "$tag"
diff --git a/melos.yaml b/melos.yaml
index b2fc2a82..7d6d190a 100644
--- a/melos.yaml
+++ b/melos.yaml
@@ -119,10 +119,14 @@ scripts:
   release:tag:
     run: |
       melos exec -c 1 --no-published --no-private --order-dependents -- \
-        "\$MELOS_ROOT_PATH/.github/workflows/scripts/tag_package.sh"
+        "git tag \$MELOS_PACKAGE_NAME-v\$MELOS_PACKAGE_VERSION || true"
+      for tag in $(git tag --points-at HEAD); do git push origin "$tag"; done
     description: |
-      Tag unpublished packages (`<package>-v<version>`) and push, in dependency
-      order. Used by the release_tag workflow.
+      Tag unpublished packages (`<package>-v<version>`) at HEAD, then push only
+      the tags that point at HEAD β€” one at a time, so each emits its own tag
+      event (one publish run each). `|| true` skips tags that already exist;
+      pushing only HEAD tags means a stale tag on an old commit is ignored, never
+      blocking the release. Used by the release_tag workflow.
 
   release:pub:
     run: melos exec -c 1 --no-published --no-private --order-dependents -- "flutter pub publish -f"

From bc02c182ca4f157f4ea7a36298fa36d823500c35 Mon Sep 17 00:00:00 2001
From: Sahil Kumar <sahil@getstream.io>
Date: Fri, 31 Jul 2026 13:39:29 +0200
Subject: [PATCH 29/36] ci(repo): push tags in dependency order; document
 state-derived tagging
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Address review feedback:

- release:tag pushed via `git tag --points-at HEAD`, which git sorts
  lexicographically β€” so "dependency order" held only by the accident that
  `-` sorts before `_`. Push inside the ordered `melos exec` instead, so a
  dependency's tag is genuinely pushed (and published) before its dependent's.
  The docs' "tags push in dependency order" claim is now true.
- Document that tagging is state-derived: it tags every unpublished package,
  not just the ones a PR bumped β€” so keep version bumps to release PRs, and
  publish a brand-new package before releasing anything that depends on it
  (else the dependent's wait polls for a version that never lands).
- Make that wait's timeout message name the new-package cause instead of a
  bare "re-run once it's published".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .github/workflows/release_publish.yml |  2 +-
 STYLE_GUIDE.md                        | 20 +++++++++++++++++++-
 melos.yaml                            | 13 ++++++-------
 3 files changed, 26 insertions(+), 9 deletions(-)

diff --git a/.github/workflows/release_publish.yml b/.github/workflows/release_publish.yml
index daf5212f..be8822c6 100644
--- a/.github/workflows/release_publish.yml
+++ b/.github/workflows/release_publish.yml
@@ -110,7 +110,7 @@ jobs:
             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/$want"; do
-              (( SECONDS < deadline )) || { echo "::error ::Timed out waiting for $dep v$want. Re-run once it's published."; exit 1; }
+              (( 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
diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md
index aa31ec47..48d21a9e 100644
--- a/STYLE_GUIDE.md
+++ b/STYLE_GUIDE.md
@@ -1415,11 +1415,29 @@ publishing, `release_publish.yml`'s **⏳ Wait for in-workspace dependencies** s
 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 is usually a single poll. If a dependency's publish genuinely *fails*,
+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; publishing is idempotent,
 so re-runs are safe.
 
+**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 7d6d190a..d54ca1c8 100644
--- a/melos.yaml
+++ b/melos.yaml
@@ -119,14 +119,13 @@ scripts:
   release:tag:
     run: |
       melos exec -c 1 --no-published --no-private --order-dependents -- \
-        "git tag \$MELOS_PACKAGE_NAME-v\$MELOS_PACKAGE_VERSION || true"
-      for tag in $(git tag --points-at HEAD); do git push origin "$tag"; done
+        "if git tag \$MELOS_PACKAGE_NAME-v\$MELOS_PACKAGE_VERSION 2>/dev/null; then git push origin \$MELOS_PACKAGE_NAME-v\$MELOS_PACKAGE_VERSION; fi"
     description: |
-      Tag unpublished packages (`<package>-v<version>`) at HEAD, then push only
-      the tags that point at HEAD β€” one at a time, so each emits its own tag
-      event (one publish run each). `|| true` skips tags that already exist;
-      pushing only HEAD tags means a stale tag on an old commit is ignored, never
-      blocking the release. Used by the release_tag workflow.
+      Tag unpublished packages (`<package>-v<version>`) at HEAD and push each, in
+      dependency order, one at a time β€” so a dependency publishes before its
+      dependent and each push emits its own tag event (one publish run). A tag
+      that already exists (e.g. a stale tag on an old commit) fails `git tag`, so
+      it is skipped and never pushed. Used by the release_tag workflow.
 
   release:pub:
     run: melos exec -c 1 --no-published --no-private --order-dependents -- "flutter pub publish -f"

From 69b418d09d8b917e7f5683ee9e4bcd61c8c6f8fd Mon Sep 17 00:00:00 2001
From: Sahil Kumar <sahil@getstream.io>
Date: Fri, 31 Jul 2026 16:32:54 +0200
Subject: [PATCH 30/36] =?UTF-8?q?ci(repo):=20address=20review=20=E2=80=94?=
 =?UTF-8?q?=20meaningful=20dry-run,=20dep=20filter,=20publish=20verify,=20?=
 =?UTF-8?q?dispatch?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

From renefloor's review:

- lint:pub: drop `--no-published` so the dry run isn't a no-op in steady state;
  MELOS_PACKAGES scopes it in CI and `pub publish -n` doesn't check collisions.
- Wait step: skip private in-workspace deps (they never publish, so waiting on
  one would hang until timeout); url-encode a build-metadata '+'.
- Add a post-publish "Verify published" step so a no-op publish can't cut a
  GitHub Release for a version that isn't on pub.dev.
- release:tag: warn (`::warning`) when a stale tag causes a package to be
  skipped, instead of silently dropping it from the release.
- release_tag.yml: add workflow_dispatch recovery + widen the gate for it.
- Skill: `grep -A6` -> `-A12` (was cutting off the scope map); fix multi-package
  title to match STYLE_GUIDE (`chore(repo): release packages`).
- STYLE_GUIDE: drop a stray blank line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .claude/skills/release-pr/SKILL.md    |  4 +--
 .github/workflows/release_publish.yml | 36 ++++++++++++++++++++++++---
 .github/workflows/release_tag.yml     |  7 ++++--
 STYLE_GUIDE.md                        |  1 -
 melos.yaml                            | 13 +++++++---
 5 files changed, 48 insertions(+), 13 deletions(-)

diff --git a/.claude/skills/release-pr/SKILL.md b/.claude/skills/release-pr/SKILL.md
index d4a14732..38641899 100644
--- a/.claude/skills/release-pr/SKILL.md
+++ b/.claude/skills/release-pr/SKILL.md
@@ -22,7 +22,7 @@ allowed-tools:
 # release-pr
 
 Opens a release PR for stream-core-flutter. Branch `release/<...>` β†’ base `main` β†’ title
-`chore(<scope>): release <package> vX.Y.Z` (single package) or `chore(repo): release <...>` (multiple).
+`chore(<scope>): release <package> 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 (`<pkg>-vX.Y.Z`) and
@@ -46,7 +46,7 @@ scope to a package path); read that map rather than hard-coding it, so adding a
 package needs no change here:
 
 ```bash
-grep -A6 'semantic_changelog_update' .github/workflows/pr_title.yml
+grep -A12 'semantic_changelog_update' .github/workflows/pr_title.yml
 ```
 
 ## Inputs
diff --git a/.github/workflows/release_publish.yml b/.github/workflows/release_publish.yml
index be8822c6..5911b63e 100644
--- a/.github/workflows/release_publish.yml
+++ b/.github/workflows/release_publish.yml
@@ -98,18 +98,27 @@ jobs:
         run: |
           set -euo pipefail
 
-          # Dep names from the graph; versions from list --json (dir name may differ).
+          # Dep names from the graph (all in-workspace deps, incl. dev/override);
+          # versions from list --json (dir name may differ). Skip private deps β€”
+          # they never publish, so waiting on one would hang until timeout.
           info="$(melos list --json 2>/dev/null)"
-          deps="$(melos list --graph 2>/dev/null | awk '/^\{/{f=1} f{print} /^\}$/{f=0}' \
+          raw="$(melos list --graph 2>/dev/null | awk '/^\{/{f=1} f{print} /^\}$/{f=0}' \
             | jq -r --arg p "$PKG" '.[$p] // [] | .[]')"
 
-          [[ -z "$deps" ]] && { echo "βœ… $PKG has no in-workspace dependencies."; exit 0; }
+          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/$want"; do
+            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
@@ -122,6 +131,25 @@ jobs:
         env:
           MELOS_PACKAGES: ${{ steps.parse.outputs.package }}
 
+      - name: βœ… Verify published
+        # Guard against a no-op publish (e.g. release:pub matching zero packages)
+        # cutting a GitHub Release for a version that never reached pub.dev.
+        shell: bash
+        env:
+          PKG: ${{ steps.parse.outputs.package }}
+          VERSION: ${{ steps.parse.outputs.version }}
+        run: |
+          set -euo pipefail
+          enc="${VERSION//+/%2B}" # url-encode a build-metadata '+'
+          echo "⏳ Confirming $PKG v$VERSION is live on pub.dev…"
+          deadline=$((SECONDS + 300)) # 5 minutes
+          until curl -sfL --connect-timeout 10 --max-time 30 -o /dev/null "https://pub.dev/api/packages/$PKG/versions/$enc"; do
+            (( SECONDS < deadline )) || { echo "::error ::$PKG v$VERSION is not on pub.dev after the publish step (it may have matched no packages); no GitHub Release was created."; 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
diff --git a/.github/workflows/release_tag.yml b/.github/workflows/release_tag.yml
index 04b5daa2..262a60c4 100644
--- a/.github/workflows/release_tag.yml
+++ b/.github/workflows/release_tag.yml
@@ -3,6 +3,7 @@ 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).
@@ -11,8 +12,10 @@ concurrency:
 
 jobs:
   release:
-    # No regex in GH expressions; match the `chore(<scope>): release` prefix.
-    if: "${{ startsWith(github.event.head_commit.message, 'chore(') && contains(github.event.head_commit.message, '): release') }}"
+    # Run on a manual dispatch, or when the commit matches the `chore(<scope>):
+    # release` prefix (no regex in GH expressions). Tags derive from package
+    # state, so a manual run tags whatever is currently 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
diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md
index 48d21a9e..25ed3845 100644
--- a/STYLE_GUIDE.md
+++ b/STYLE_GUIDE.md
@@ -1438,7 +1438,6 @@ only the ones this PR bumped. So:
   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
 
 - **Repo-wide overview**: [`CLAUDE.md`](CLAUDE.md) β€” architecture, commands, package
diff --git a/melos.yaml b/melos.yaml
index d54ca1c8..81b58b92 100644
--- a/melos.yaml
+++ b/melos.yaml
@@ -113,19 +113,24 @@ scripts:
        - Note: you can also rely on your IDEs Dart Analysis / Issues window.
 
   lint:pub:
-    run: melos exec -c 1 --no-published --no-private --order-dependents -- "flutter pub publish -n"
-    description: Dry run `pub publish` for unpublished packages, in dependency order.
+    run: melos exec -c 1 --no-private --order-dependents -- "flutter pub publish -n"
+    description: |
+      Dry run `pub publish` for all non-private packages, in dependency order.
+      No `--no-published`: `pub publish -n` never publishes and doesn't check
+      version collisions, so it stays a useful "are these publishable?" check
+      even for already-released versions. The publish workflow scopes it to the
+      tagged package via MELOS_PACKAGES.
 
   release:tag:
     run: |
       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; fi"
+        "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"
     description: |
       Tag unpublished packages (`<package>-v<version>`) at HEAD and push each, in
       dependency order, one at a time β€” so a dependency publishes before its
       dependent and each push emits its own tag event (one publish run). A tag
       that already exists (e.g. a stale tag on an old commit) fails `git tag`, so
-      it is skipped and never pushed. Used by the release_tag workflow.
+      it is skipped (with a `::warning`) and never pushed. Used by release_tag.
 
   release:pub:
     run: melos exec -c 1 --no-published --no-private --order-dependents -- "flutter pub publish -f"

From fb750879984a1187f4c2d64e5a71a10ede164510 Mon Sep 17 00:00:00 2001
From: Sahil Kumar <sahil@getstream.io>
Date: Fri, 31 Jul 2026 18:14:56 +0200
Subject: [PATCH 31/36] ci(repo): make publish idempotent via pub.dev state,
 not melos --no-published

Re-running release_publish for an already-published tag hit `flutter pub publish`
"version already exists" because `melos --no-published` doesn't reliably exclude
published versions in CI. Collapse publish + verify into one step that checks the
live per-version endpoint first: skip if the version is already there (a true
no-op), otherwise publish and confirm it landed before the release is cut. This
makes workflow_dispatch recovery safe whether the prior run failed or succeeded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .github/workflows/release_publish.yml | 30 ++++++++++++++++++---------
 STYLE_GUIDE.md                        |  6 ++++--
 2 files changed, 24 insertions(+), 12 deletions(-)

diff --git a/.github/workflows/release_publish.yml b/.github/workflows/release_publish.yml
index 5911b63e..96f75a6e 100644
--- a/.github/workflows/release_publish.yml
+++ b/.github/workflows/release_publish.yml
@@ -127,24 +127,34 @@ jobs:
           done
 
       - name: πŸ“’ Publish to pub.dev
-        run: melos run release:pub
-        env:
-          MELOS_PACKAGES: ${{ steps.parse.outputs.package }}
-
-      - name: βœ… Verify published
-        # Guard against a no-op publish (e.g. release:pub matching zero packages)
-        # cutting a GitHub Release for a version that never reached 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 '+'
-          echo "⏳ Confirming $PKG v$VERSION is live on pub.dev…"
+          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 "https://pub.dev/api/packages/$PKG/versions/$enc"; do
-            (( SECONDS < deadline )) || { echo "::error ::$PKG v$VERSION is not on pub.dev after the publish step (it may have matched no packages); no GitHub Release was created."; exit 1; }
+          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
diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md
index 25ed3845..8be2ec9e 100644
--- a/STYLE_GUIDE.md
+++ b/STYLE_GUIDE.md
@@ -1419,8 +1419,10 @@ 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; publishing is idempotent,
-so re-runs are safe.
+(`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

From 007c966fedeb859eaf200b6cdb7acb66ae475dd8 Mon Sep 17 00:00:00 2001
From: Sahil Kumar <sahil@getstream.io>
Date: Fri, 31 Jul 2026 18:18:27 +0200
Subject: [PATCH 32/36] ci(repo): publish directly instead of via melos run
 release:pub
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

One tag = one package, and idempotency is now the pub.dev per-version check β€”
so the melos exec fan-out (--order-dependents / --no-private / --no-published)
added nothing. Publish the tagged package directly with `flutter pub publish
--force` in its directory, and drop the now-unused release:pub script.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .claude/skills/release-pr/SKILL.md    |  4 ++--
 .github/workflows/release_publish.yml | 17 ++++++++++-------
 melos.yaml                            |  8 --------
 3 files changed, 12 insertions(+), 17 deletions(-)

diff --git a/.claude/skills/release-pr/SKILL.md b/.claude/skills/release-pr/SKILL.md
index 38641899..49a8ba86 100644
--- a/.claude/skills/release-pr/SKILL.md
+++ b/.claude/skills/release-pr/SKILL.md
@@ -157,7 +157,7 @@ publish job waits for in-workspace dependencies to be live first).
 
 - **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`** (or `release:tag`) locally β€” those are the CI publish/tag steps; running them
-  publishes from an unreviewed tree. Refuse even if asked.
+- **Never run `melos run release:tag` or `flutter pub publish` locally** β€” those are the CI tag/publish steps;
+  running them tags or publishes from an unreviewed tree. Refuse even if asked.
 - **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
index 96f75a6e..05a8a717 100644
--- a/.github/workflows/release_publish.yml
+++ b/.github/workflows/release_publish.yml
@@ -82,7 +82,7 @@ jobs:
         run: melos bootstrap --verbose
 
       - name: 🌡 Dry Run
-        # MELOS_PACKAGES scopes lint:pub / release:pub to just the tagged package.
+        # MELOS_PACKAGES scopes lint:pub to just the tagged package.
         run: melos run lint:pub
         env:
           MELOS_PACKAGES: ${{ steps.parse.outputs.package }}
@@ -127,14 +127,14 @@ jobs:
           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.
+        # One tag = one package, so publish it directly. 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 '+'
@@ -146,8 +146,11 @@ jobs:
             exit 0
           fi
 
-          echo "πŸ“¦ Publishing $PKG v$VERSION…"
-          melos run release:pub
+          # Publish just this package. Resolve its location via melos (the
+          # directory name may differ from the package name).
+          pkg_path="$(melos list --json 2>/dev/null | jq -r --arg n "$PKG" '.[] | select(.name==$n) | .location')"
+          echo "πŸ“¦ Publishing $PKG v$VERSION from $pkg_path…"
+          (cd "$pkg_path" && flutter pub publish --force)
 
           # 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).
diff --git a/melos.yaml b/melos.yaml
index 81b58b92..108d0e80 100644
--- a/melos.yaml
+++ b/melos.yaml
@@ -132,14 +132,6 @@ scripts:
       that already exists (e.g. a stale tag on an old commit) fails `git tag`, so
       it is skipped (with a `::warning`) and never pushed. Used by release_tag.
 
-  release:pub:
-    run: melos exec -c 1 --no-published --no-private --order-dependents -- "flutter pub publish -f"
-    description: |
-      Publish unpublished packages to pub.dev (OIDC in CI). Set MELOS_PACKAGES
-      to scope to one package. Re-runs are a no-op (`--no-published`). Used by
-      the release_publish workflow, which first waits for in-workspace
-      dependencies to be live so publish never fails on a missing dependency.
-
   generate:all:
     run: melos run generate:dart && melos run generate:flutter
     description: Build all generated files for Dart & Flutter packages in this project.

From 6342e3a3935d5be478f861243af9fde61f9e794d Mon Sep 17 00:00:00 2001
From: Sahil Kumar <sahil@getstream.io>
Date: Fri, 31 Jul 2026 18:21:46 +0200
Subject: [PATCH 33/36] Revert "ci(repo): publish directly instead of via melos
 run release:pub"

This reverts commit 007c966fedeb859eaf200b6cdb7acb66ae475dd8.
---
 .claude/skills/release-pr/SKILL.md    |  4 ++--
 .github/workflows/release_publish.yml | 17 +++++++----------
 melos.yaml                            |  8 ++++++++
 3 files changed, 17 insertions(+), 12 deletions(-)

diff --git a/.claude/skills/release-pr/SKILL.md b/.claude/skills/release-pr/SKILL.md
index 49a8ba86..38641899 100644
--- a/.claude/skills/release-pr/SKILL.md
+++ b/.claude/skills/release-pr/SKILL.md
@@ -157,7 +157,7 @@ publish job waits for in-workspace dependencies to be live first).
 
 - **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:tag` or `flutter pub publish` locally** β€” those are the CI tag/publish steps;
-  running them tags or publishes from an unreviewed tree. Refuse even if asked.
+- **Never run `melos run release:pub`** (or `release:tag`) locally β€” those are the CI publish/tag steps; running them
+  publishes from an unreviewed tree. Refuse even if asked.
 - **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
index 05a8a717..96f75a6e 100644
--- a/.github/workflows/release_publish.yml
+++ b/.github/workflows/release_publish.yml
@@ -82,7 +82,7 @@ jobs:
         run: melos bootstrap --verbose
 
       - name: 🌡 Dry Run
-        # MELOS_PACKAGES scopes lint:pub to just the tagged package.
+        # MELOS_PACKAGES scopes lint:pub / release:pub to just the tagged package.
         run: melos run lint:pub
         env:
           MELOS_PACKAGES: ${{ steps.parse.outputs.package }}
@@ -127,14 +127,14 @@ jobs:
           done
 
       - name: πŸ“’ Publish to pub.dev
-        # One tag = one package, so publish it directly. 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.
+        # 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 '+'
@@ -146,11 +146,8 @@ jobs:
             exit 0
           fi
 
-          # Publish just this package. Resolve its location via melos (the
-          # directory name may differ from the package name).
-          pkg_path="$(melos list --json 2>/dev/null | jq -r --arg n "$PKG" '.[] | select(.name==$n) | .location')"
-          echo "πŸ“¦ Publishing $PKG v$VERSION from $pkg_path…"
-          (cd "$pkg_path" && flutter pub publish --force)
+          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).
diff --git a/melos.yaml b/melos.yaml
index 108d0e80..81b58b92 100644
--- a/melos.yaml
+++ b/melos.yaml
@@ -132,6 +132,14 @@ scripts:
       that already exists (e.g. a stale tag on an old commit) fails `git tag`, so
       it is skipped (with a `::warning`) and never pushed. Used by release_tag.
 
+  release:pub:
+    run: melos exec -c 1 --no-published --no-private --order-dependents -- "flutter pub publish -f"
+    description: |
+      Publish unpublished packages to pub.dev (OIDC in CI). Set MELOS_PACKAGES
+      to scope to one package. Re-runs are a no-op (`--no-published`). Used by
+      the release_publish workflow, which first waits for in-workspace
+      dependencies to be live so publish never fails on a missing dependency.
+
   generate:all:
     run: melos run generate:dart && melos run generate:flutter
     description: Build all generated files for Dart & Flutter packages in this project.

From 10df94bef69d44996b08b12ccc96d2693fcb0974 Mon Sep 17 00:00:00 2001
From: Sahil Kumar <sahil@getstream.io>
Date: Fri, 31 Jul 2026 18:28:58 +0200
Subject: [PATCH 34/36] ci(repo): inline tagging into release_tag.yml, drop
 release:tag script
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Mirror stream-chat-flutter: the tagging logic lives in the workflow, not a melos
script. Move the `melos exec … git tag/push` command straight into
release_tag.yml's run step and remove the now-single-use release:tag from
melos.yaml. Behaviour is unchanged (same command, same dependency-ordered
push + stale-tag ::warning); release:pub stays a melos script as in chat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .claude/skills/release-pr/SKILL.md |  4 ++--
 .github/workflows/release_tag.yml  | 10 ++++++++--
 melos.yaml                         | 11 -----------
 3 files changed, 10 insertions(+), 15 deletions(-)

diff --git a/.claude/skills/release-pr/SKILL.md b/.claude/skills/release-pr/SKILL.md
index 38641899..c4ccef34 100644
--- a/.claude/skills/release-pr/SKILL.md
+++ b/.claude/skills/release-pr/SKILL.md
@@ -157,7 +157,7 @@ publish job waits for in-workspace dependencies to be live first).
 
 - **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`** (or `release:tag`) locally β€” those are the CI publish/tag steps; running them
-  publishes from an unreviewed tree. Refuse even if asked.
+- **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_tag.yml b/.github/workflows/release_tag.yml
index 262a60c4..378a6489 100644
--- a/.github/workflows/release_tag.yml
+++ b/.github/workflows/release_tag.yml
@@ -36,10 +36,16 @@ jobs:
         run: flutter pub global activate melos
 
       - name: 🏷️ Tag unpublished packages
-        # Tags derive from package state, not the commit message. See release:tag.
+        # Tag every package whose current version isn't on pub.dev (state-derived,
+        # not parsed from the commit message) and push each, in dependency order,
+        # one at a time β€” so a dependency publishes before its dependent and each
+        # push emits its own tag event (one publish run). A tag that already
+        # exists (e.g. a stale tag on an old commit) fails `git tag`, so it is
+        # skipped (with a `::warning`) and never pushed.
         shell: bash
         run: |
           set -euo pipefail
           git config user.name "Stream SDK Bot"
           git config user.email "60655709+Stream-SDK-Bot@users.noreply.github.com"
-          melos run release:tag
+          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/melos.yaml b/melos.yaml
index 81b58b92..423536d0 100644
--- a/melos.yaml
+++ b/melos.yaml
@@ -121,17 +121,6 @@ scripts:
       even for already-released versions. The publish workflow scopes it to the
       tagged package via MELOS_PACKAGES.
 
-  release:tag:
-    run: |
-      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"
-    description: |
-      Tag unpublished packages (`<package>-v<version>`) at HEAD and push each, in
-      dependency order, one at a time β€” so a dependency publishes before its
-      dependent and each push emits its own tag event (one publish run). A tag
-      that already exists (e.g. a stale tag on an old commit) fails `git tag`, so
-      it is skipped (with a `::warning`) and never pushed. Used by release_tag.
-
   release:pub:
     run: melos exec -c 1 --no-published --no-private --order-dependents -- "flutter pub publish -f"
     description: |

From 7440628bc30bcb4bff7cc54d63bd1fdf0e7492b8 Mon Sep 17 00:00:00 2001
From: Sahil Kumar <sahil@getstream.io>
Date: Fri, 31 Jul 2026 18:35:09 +0200
Subject: [PATCH 35/36] docs(repo): tighten release-workflow comments
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Trim the verbose comments (tag step, dependency wait, dep resolution, gate) to
one or two terse lines each, and fix the release:pub / lint:pub descriptions β€”
release:pub no longer relies on --no-published for idempotency (the publish
step's live-check does), so the description no longer claims it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .github/workflows/release_publish.yml | 10 ++++------
 .github/workflows/release_tag.yml     | 14 +++++---------
 melos.yaml                            | 15 ++++++---------
 3 files changed, 15 insertions(+), 24 deletions(-)

diff --git a/.github/workflows/release_publish.yml b/.github/workflows/release_publish.yml
index 96f75a6e..4dad0ce0 100644
--- a/.github/workflows/release_publish.yml
+++ b/.github/workflows/release_publish.yml
@@ -82,24 +82,22 @@ jobs:
         run: melos bootstrap --verbose
 
       - name: 🌡 Dry Run
-        # MELOS_PACKAGES scopes lint:pub / release:pub to just the tagged package.
+        # 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
-        # publishes in its own OIDC run), which the server rejects. Wait until
-        # each in-workspace dependency is live β€” via the per-version endpoint,
-        # fresh in CI unlike the listing `melos --published` reads.
+        # 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
 
-          # Dep names from the graph (all in-workspace deps, incl. dev/override);
-          # versions from list --json (dir name may differ). Skip private deps β€”
+          # 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}' \
diff --git a/.github/workflows/release_tag.yml b/.github/workflows/release_tag.yml
index 378a6489..7bc3a28a 100644
--- a/.github/workflows/release_tag.yml
+++ b/.github/workflows/release_tag.yml
@@ -12,9 +12,8 @@ concurrency:
 
 jobs:
   release:
-    # Run on a manual dispatch, or when the commit matches the `chore(<scope>):
-    # release` prefix (no regex in GH expressions). Tags derive from package
-    # state, so a manual run tags whatever is currently unpublished.
+    # 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:
@@ -36,12 +35,9 @@ jobs:
         run: flutter pub global activate melos
 
       - name: 🏷️ Tag unpublished packages
-        # Tag every package whose current version isn't on pub.dev (state-derived,
-        # not parsed from the commit message) and push each, in dependency order,
-        # one at a time β€” so a dependency publishes before its dependent and each
-        # push emits its own tag event (one publish run). A tag that already
-        # exists (e.g. a stale tag on an old commit) fails `git tag`, so it is
-        # skipped (with a `::warning`) and never pushed.
+        # 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
diff --git a/melos.yaml b/melos.yaml
index 423536d0..2f2404c5 100644
--- a/melos.yaml
+++ b/melos.yaml
@@ -115,19 +115,16 @@ scripts:
   lint:pub:
     run: melos exec -c 1 --no-private --order-dependents -- "flutter pub publish -n"
     description: |
-      Dry run `pub publish` for all non-private packages, in dependency order.
-      No `--no-published`: `pub publish -n` never publishes and doesn't check
-      version collisions, so it stays a useful "are these publishable?" check
-      even for already-released versions. The publish workflow scopes it to the
-      tagged package via MELOS_PACKAGES.
+      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 unpublished packages to pub.dev (OIDC in CI). Set MELOS_PACKAGES
-      to scope to one package. Re-runs are a no-op (`--no-published`). Used by
-      the release_publish workflow, which first waits for in-workspace
-      dependencies to be live so publish never fails on a missing dependency.
+      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

From 998e449add417df34ccd209b0bec3ee48a3ee81e Mon Sep 17 00:00:00 2001
From: Sahil Kumar <sahil@getstream.io>
Date: Fri, 31 Jul 2026 19:38:54 +0200
Subject: [PATCH 36/36] =?UTF-8?q?ci(repo):=20address=20self-review=20?=
 =?UTF-8?q?=E2=80=94=20drop=20unused=20PAT,=20resolve=20pubspec=20by=20nam?=
 =?UTF-8?q?e,=20docs?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- release_publish checkout: use the default GITHUB_TOKEN (the job never pushes;
  the release step passes its own token), so no write-scoped PAT sits in git
  config across the melos install/bootstrap steps.
- Parse step: resolve the package's pubspec by grepping for its `name:` instead
  of assuming packages/<name>/ β€” robust to a dir that differs from the package
  name (melos isn't installed yet at this step).
- release_tag: drop the dead `git config user.*` β€” the tags are lightweight
  (`git tag <name>`), which record no tagger.
- Document that release PRs must be squash-merged (the tag gate reads the tip
  commit's message; a merge commit would silently bypass it).

Validated on the test repo: checkout with the default token, parse-by-name, and
tag-without-identity all pass; the manual workflow_dispatch recovery path runs
green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .claude/skills/release-pr/SKILL.md    |  4 ++++
 .github/workflows/release_publish.yml | 11 +++++++----
 .github/workflows/release_tag.yml     |  2 --
 STYLE_GUIDE.md                        |  6 ++++++
 4 files changed, 17 insertions(+), 6 deletions(-)

diff --git a/.claude/skills/release-pr/SKILL.md b/.claude/skills/release-pr/SKILL.md
index c4ccef34..4701929d 100644
--- a/.claude/skills/release-pr/SKILL.md
+++ b/.claude/skills/release-pr/SKILL.md
@@ -147,6 +147,10 @@ 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
diff --git a/.github/workflows/release_publish.yml b/.github/workflows/release_publish.yml
index 4dad0ce0..d7a66a40 100644
--- a/.github/workflows/release_publish.yml
+++ b/.github/workflows/release_publish.yml
@@ -21,10 +21,11 @@ jobs:
     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
-          token: ${{ secrets.BOT_GITHUB_API_TOKEN }}
 
       - name: 🏷️ Parse package and version from tag
         id: parse
@@ -44,9 +45,11 @@ jobs:
           pkg="${BASH_REMATCH[1]}"
           version="${BASH_REMATCH[2]}"
 
-          pubspec="packages/$pkg/pubspec.yaml"
-          if [[ ! -f "$pubspec" ]]; then
-            echo "::error ::No package found at $pubspec."
+          # 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
 
diff --git a/.github/workflows/release_tag.yml b/.github/workflows/release_tag.yml
index 7bc3a28a..0a6f864a 100644
--- a/.github/workflows/release_tag.yml
+++ b/.github/workflows/release_tag.yml
@@ -41,7 +41,5 @@ jobs:
         shell: bash
         run: |
           set -euo pipefail
-          git config user.name "Stream SDK Bot"
-          git config user.email "60655709+Stream-SDK-Bot@users.noreply.github.com"
           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 8be2ec9e..d124e397 100644
--- a/STYLE_GUIDE.md
+++ b/STYLE_GUIDE.md
@@ -1397,6 +1397,12 @@ Title the PR `chore(repo): release packages` for a multi-package release
 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