Skip to content

feat(cli): unified cross-platform patch numbering#3738

Open
easymac wants to merge 6 commits into
mainfrom
mc/patch-unification
Open

feat(cli): unified cross-platform patch numbering#3738
easymac wants to merge 6 commits into
mainfrom
mc/patch-unification

Conversation

@easymac
Copy link
Copy Markdown
Contributor

@easymac easymac commented May 4, 2026

Summary

Make POST /v1/apps/{appId}/patches idempotent on a user-supplied correlation key so cross-platform builds resolve to one patch row instead of one per platform.

Customer outcome: a developer who shipped "patch 3" sees the same number on iOS and Android — in their analytics, in-app, and in their release notes. The dominant CI flow (iOS bot publishes, Android bot publishes minutes later, no shared state between them) collapses to one patch as long as both bots pass the same --patch-id.

Protocol — shorebird_code_push_protocol

  • CreatePatchRequest gains optional clientPatchId (wire field client_patch_id). toJson always emits the key (null when unset) so server parsing doesn't have to special-case absence.
  • CreatePatchResponse (new field) echoes clientPatchId back so callers can verify whether an idempotent re-use occurred.
  • ReleasePatch carries clientPatchId so existing GET endpoints surface the correlation key for admin/console rendering.

Client — shorebird_code_push_client

  • CodePushClient.createPatch accepts and threads clientPatchId, and now returns CreatePatchResponse instead of Patch so callers can read the echoed key.
  • Empty-string clientPatchId is normalized to null at the client boundary — a caller that passes an unexpanded template variable or empty flag shouldn't land on the idempotent path keyed on '' and inherit a stranger's patch. Done at the client rather than the protocol so the protocol layer remains a faithful round-trip.

CLI — shorebird_cli

  • New --patch-id=<string> flag on shorebird patch. Common usage: --patch-id=${{ github.sha }} or any stable token (hotfix-login, a CI run ID, etc.).
  • Multi-platform invocations (--platforms=ios,android) auto-generate a UUID and share it across every per-platform fan-out — single-invocation is just cross-invocation-with-an-internal-ID.
  • Append-after-promotion confirmation: when an idempotent hit lands on a patch already promoted to stable, the new platform's artifacts go live to that platform's stable users immediately. In interactive mode the CLI prompts before continuing; in CI it proceeds silently (the iOS-publishes-then-Android-completes flow is the canonical use case and prompting would deadlock).
  • Suppresses the prompt when the same run promoted the patch (e.g. the first platform of a single --platforms ios,android invocation — the user already opted in by passing both platforms together).
  • Success line: ✅ Published Patch 3 (iOS, Android) instead of one log per platform; degenerate cases where platforms land on different numbers each get their own line.

Backwards compatibility

  • Missing client_patch_id deserializes to null. Old clients that don't set the field fall through the existing per-call create path on the server.
  • Old servers that don't recognize the field ignore it on deserialization; the CLI flag becomes a no-op until the server side ships, with no error surface.

This is the protocol + CLI half of unified patch numbering. The server-side migration and idempotent createPatch live in the private parent repo and are gated behind the same client_patch_id correlation key.

Test plan

  • dart test packages/shorebird_code_push_protocol
  • dart test packages/shorebird_code_push_client
  • dart test packages/shorebird_cli
  • Manual: shorebird patch android --patch-id=test-sha against a server with the matching server-side change deployed
  • Manual: shorebird patch --platforms=ios,android end-to-end (single invocation; auto-generated UUID)
  • Manual: simulate cross-invocation by running two shorebird patch calls minutes apart with the same --patch-id
  • Manual: append-after-promotion in interactive TTY (expect prompt) and in CI/non-TTY (expect silent proceed)

Make `POST /v1/apps/{appId}/patches` idempotent on a user-supplied
correlation key so cross-platform builds resolve to one patch row
instead of one per platform.

Customer outcome: a developer who shipped "patch 3" sees the same
number on iOS and Android — in their analytics, in-app, and in their
release notes. The dominant CI flow (iOS bot publishes, Android bot
publishes minutes later, no shared state between them) collapses to
one patch as long as both bots pass the same `--patch-id`.

## Protocol — shorebird_code_push_protocol

- `CreatePatchRequest` gains optional `clientPatchId` (wire field
  `client_patch_id`). `toJson` always emits the key (null when unset)
  so server parsing doesn't have to special-case absence.
- `CreatePatchResponse` (new field) echoes `clientPatchId` back so
  callers can verify whether an idempotent re-use occurred.
- `ReleasePatch` carries `clientPatchId` so existing GET endpoints
  surface the correlation key for admin/console rendering.

## Client — shorebird_code_push_client

- `CodePushClient.createPatch` accepts and threads `clientPatchId`,
  and now returns `CreatePatchResponse` instead of `Patch` so callers
  can read the echoed key.
- Empty-string `clientPatchId` is normalized to null at the client
  boundary — a caller that passes an unexpanded template variable or
  empty flag shouldn't land on the idempotent path keyed on `''` and
  inherit a stranger's patch. Done at the client rather than the
  protocol so the protocol layer remains a faithful round-trip.

## CLI — shorebird_cli

- New `--patch-id=<string>` flag on `shorebird patch`. Common usage:
  `--patch-id=\${{ github.sha }}` or any stable token
  (`hotfix-login`, a CI run ID, etc.).
- Multi-platform invocations (`--platforms=ios,android`) auto-generate
  a UUID and share it across every per-platform fan-out — single-
  invocation is just cross-invocation-with-an-internal-ID.
- Append-after-promotion confirmation: when an idempotent hit lands
  on a patch already promoted to stable, the new platform's artifacts
  go live to that platform's stable users immediately. In interactive
  mode the CLI prompts before continuing; in CI it proceeds silently
  (the iOS-publishes-then-Android-completes flow is the canonical
  use case and prompting would deadlock).
- Suppresses the prompt when the same run promoted the patch (e.g.
  the first platform of a single `--platforms ios,android` invocation
  — the user already opted in by passing both platforms together).
- Success line: `✅ Published Patch 3 (iOS, Android)` instead of one
  log per platform; degenerate cases where platforms land on
  different numbers each get their own line.

## Backwards compatibility

- Missing `client_patch_id` deserializes to null. Old clients that
  don't set the field fall through the existing per-call create path
  on the server.
- Old servers that don't recognize the field ignore it on
  deserialization; the CLI flag becomes a no-op until the server
  side ships, with no error surface.

This is the protocol + CLI half of unified patch numbering. The
server-side migration and idempotent createPatch live in the parent
repo and are gated behind the same `client_patch_id` correlation key.
@codecov
Copy link
Copy Markdown

codecov Bot commented May 4, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

easymac added 2 commits May 4, 2026 16:03
CI was failing on `dart format --set-exit-if-changed`. The cascade
literals in the unified-success log tests exceeded 80 columns and
need to be split across multiple lines.
@easymac easymac marked this pull request as ready for review May 5, 2026 17:41

final List<ReleasePatch> existing;
try {
existing = await codePushClient.getPatches(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bdero's review bot:

💡 The dominant CI flow is "every run has a unique SHA" — so every publishPatch is a genuinely new patch row, but we still fetch the full patch list on every one of them just to confirm "not already on stable." Two server round-trips per platform instead of one, scaling with release-patch-count on the read path.

The cleanest fix lives server-side: have CreatePatchResponse return a created: bool (or echo back the patch's current channel) so the CLI can skip the getPatches lookup when the response says we created a new row. Worth raising with the server PR while it's still in flight.

return;
}

final priorState = existing.firstWhereOrNull((p) => p.id == patch.id);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bdero's review bot:

💡 firstWhereOrNull((p) => p.id == patch.id) over the full list — if getPatches ever returns a windowed/recent slice (or starts to), an older patch's promotion state silently reads as "not on stable" and the courtesy prompt vanishes. Not sure if getPatches paginates today — worth confirming, and if there's a "fetch one patch by id" endpoint we should prefer that.

Comment on lines +244 to +249
String? _resolveClientPatchId() {
final explicit = results['patch-id'] as String?;
if (explicit != null && explicit.isNotEmpty) return explicit;
if (results.releaseTypes.length > 1) return const Uuid().v4();
return null;
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bdero's review bot:

💡 if (explicit != null && explicit.isNotEmpty) return explicit; treats --patch-id="" and --patch-id (omitted) the same. The failure mode: someone writes --patch-id=${{ env.PATCH_SHA }} in their CI template, the env var is undefined, the flag arrives as "", and on a multi-platform invocation we silently generate a UUID — the iOS bot and the Android bot never converge because each generates its own. Loud failure when the flag is present but empty would surface this on the first broken CI run instead of after someone notices the patches aren't unifying.

Comment on lines 287 to +290
}) async {
// Coerce empty to null. A caller that passes an unexpanded template
// variable or empty flag would otherwise land on the idempotent path
// keyed on `''` and inherit a stranger's patch.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bdero's review bot:

💡 The PR description argues normalization belongs at the client boundary (so the protocol layer stays a faithful round-trip), which I agree with — but then _resolveClientPatchId in patch_command.dart also normalizes empty-to-null at the command layer. Pick one. If the "error on --patch-id=""" suggestion lands, the command-layer normalization goes away naturally and this stays the only normalizer.

final platforms =
byPatchNumber[number]!.map((p) => p.displayName).toList()..sort();
logger.success(
'\n✅ Published Patch $number (${platforms.join(', ')})',
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bdero's review bot:

🧹 Lost the ! — was Published Patch X!, now Published Patch X (Android, iOS). The ! matches the celebratory tone of other logger.success lines in the CLI. Published Patch 3 (Android, iOS)! keeps it consistent.

/// Used to suppress the append-after-promotion warning for the second
/// platform of a single `--platforms ios,android` invocation, where the
/// "already on stable" state was created moments earlier by this same run.
final Set<int> _patchesPromotedThisRun = {};
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bdero's review bot:

🧹 The name reads as if it tracks global state — it actually tracks "promotions made by this wrapper instance," which has command-scoped lifetime via the scoped ref. A field comment noting the scope (and that cross-invocation reuse correctly prompts because the set starts empty) would save the next reader the trace.

Comment on lines +592 to +595
// RFC4122 v4: 8-4-4-4-12 hex with version nibble == 4. Asserted
// as a single raw string to keep the regex semantics obvious;
// splitting it earned a "use raw string" / "adjacent strings"
// ping-pong from the linter.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bdero's review bot:

🧹 The "linter ping-pong" comment is more interesting than the regex it explains. Either trust the raw string and drop the comment, or hoist the regex to a top-level final RegExp _v4 = RegExp(r'...'); with a one-line "RFC 4122 v4" note — both quieter than the current four-line explanation of why-the-lint-fought-us.

/// Returns null only for single-platform invocations with no `--patch-id`,
/// preserving the legacy behavior where the server allocates a fresh patch
/// number per call.
late final String? clientPatchId = _resolveClientPatchId();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bdero's review bot:

🧹 late final String? clientPatchId = _resolveClientPatchId(); works, but _resolveClientPatchId() reads results[...] and calls Uuid().v4(), which means the order of first access decides which Uuid instance is materialized. Tests have to go through runWithOverrides for argResults to be wired before the first access — they do — but a getter (or a method returning a memoized value) reads more obviously than late final-with-side-effect.

@bdero
Copy link
Copy Markdown
Member

bdero commented May 12, 2026

Overall makes sense & approach seems good to me. Bot has some good looking suggestions. My human comment keeps getting dropped when submitting these for some reason 🤷

easymac added 3 commits May 12, 2026 16:00
- Reject `--patch-id=` (present but empty) outright in run(). Typical
  cause is an unexpanded CI template variable; silently coalescing
  to null would mean a multi-platform invocation generates a fresh
  UUID per bot and iOS/Android never converge.
- Consolidate empty-string normalization at the client boundary;
  remove the redundant `isNotEmpty` check from `_resolveClientPatchId`
  so the single normalizer lives in `CodePushClient.createPatch`.
- Restore the `!` on the aggregated success log to match the
  celebratory tone of other `logger.success` lines.
- Rename the doc comment on `_patchesPromotedThisRun` to make its
  command-scoped lifetime explicit and note that cross-invocation
  reuse still prompts (the set starts empty per command).
- Extract the RFC 4122 v4 UUID regex into a top-level constant and
  drop the linter-ping-pong commentary that obscured it.
- Add a `late final` doc note explaining the resolve-on-first-read
  contract and the `argResults`-must-be-wired prerequisite.
Address review feedback on PR #3738 (round-trip optimization).

The CLI's append-after-promotion prompt previously meant two server
round-trips per `publishPatch`: createPatch, then getPatches to check
whether the returned patch was already on stable. Every CI run with a
unique SHA — the dominant flow — paid that cost even though every
createPatch result is a freshly inserted row with no channel yet.

Add `channel` to `CreatePatchResponse` so the server can report the
patch's current promotion state inline. The CLI then:

- Skips the getPatches lookup entirely when channel is null (fresh
  insert) or non-stable.
- Only prompts when `patch.channel == "stable"`, which can only
  happen on an idempotent hit (server only populates the field when
  there's something to report).

## Backwards compatibility

Older servers omit the field; `channel` deserializes to null and
the prompt simply doesn't fire — append-after-promotion stays
allowed per the design. Older clients that don't read the field
ignore it on deserialization.

The server-side wiring (and the openapi.yaml schema update) live in
the parent repo alongside `PostgresDatabase.createPatch`.
`dart format` wraps the regex literal across multiple lines and drops
the `lines_longer_than_80_chars` ignore comment along with it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants