Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
6f4e908
feat(frontend): improve progressive rollout UX and rollout-aware uploads
cursoragent Sep 12, 2026
a1eff7e
fix(cli): rename setVersionInChannel cliHost param to options for sta…
cursoragent Sep 12, 2026
84ce108
fix(tests): warm apikey edge isolate before cli preview lifecycle tests
cursoragent Sep 12, 2026
2dc4036
chore(ci): re-trigger tests after cli-preview-lifecycle flake fix
cursoragent Sep 12, 2026
d77dcb3
docs(pr): add real console screenshots for progressive rollout UX (#3…
cursoragent Sep 12, 2026
0ce58c6
docs(pr): add before-only preprod screenshots for #3313
cursoragent Sep 13, 2026
35e3f9d
docs(pr): add after preprod progressive rollout screenshots for #3313
cursoragent Sep 13, 2026
c667b72
docs(pr-3313): recapture rollout screenshots via serve:local
cursoragent Sep 13, 2026
f368494
docs(pr-3313): remove local Vite AFTER screenshots, keep BEFORE prepr…
cursoragent Sep 13, 2026
552af79
docs(pr-3313): add AFTER screenshots from local serve:local on PR branch
cursoragent Sep 13, 2026
219afca
fix(pr-3313): address CodeRabbit review threads and remove legacy scr…
cursoragent Sep 13, 2026
c1c3ebe
test: use non-typo invalid target in bundle set_channel test
cursoragent Sep 13, 2026
54465ea
fix(pr-3313): address CodeRabbit rollout UX and capture script security
cursoragent Sep 13, 2026
638b758
fix(frontend): sort channelRolloutUi import before channelUpdatePacka…
cursoragent Sep 13, 2026
2bbf28c
fix(rollout): reset rollout on unlink and preserve rollout for self-a…
cursoragent Sep 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 26 additions & 31 deletions cli/src/bundle/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -823,6 +823,10 @@ function formatRolloutPercentage(bps: number) {
return `${Number((bps / 100).toFixed(2))}%`
}

function channelHasProgressiveRollout(channel: Pick<UploadTargetChannel, 'rollout_enabled' | 'rollout_version'>) {
return channel.rollout_enabled || channel.rollout_version != null
}

async function getVersionIdForChannelUpdate(supabase: SupabaseType, apikey: string, appid: string, bundle: string) {
const { data: versionId } = await supabase
.rpc('get_app_versions', { apikey, name_version: bundle, appid })
Expand Down Expand Up @@ -1058,7 +1062,7 @@ async function promoteExistingChannel(
targetChannel: UploadTargetChannel,
localConfig: localConfigType,
displayBundleUrl: boolean,
options?: { supaHost?: string, supaAnon?: string },
options?: Pick<OptionsUpload, 'supaHost' | 'supaAnon' | 'stable'>,
): Promise<boolean> {
const { error } = await invokeCapgoCliApi('bundle', {
apikey,
Expand All @@ -1067,6 +1071,7 @@ async function promoteExistingChannel(
app_id: appid,
version_id: versionId,
channel_id: targetChannel.id,
...(options?.stable ? { target: 'stable' } : {}),
},
supaHost: options?.supaHost,
supaAnon: options?.supaAnon,
Expand All @@ -1077,8 +1082,8 @@ async function promoteExistingChannel(
}

const bundleUrl = `${localConfig.hostWeb}/app/${appid}/channel/${targetChannel.id}`
if (targetChannel.rollout_enabled && targetChannel.rollout_version != null) {
log.warn('This channel has an active progressive rollout. Linking this bundle as the stable version resets that rollout, so devices receive the new bundle instead of the previous rollout target.')
if (!options?.stable && channelHasProgressiveRollout(targetChannel)) {
log.info('Channel has progressive rollout configured. This bundle was set as the rollout target; stable bundle stays unchanged.')
}
else if (targetChannel.public) {
log.info('Your update is now available in your public channel 🎉')
Expand All @@ -1105,7 +1110,7 @@ async function setVersionInChannel(
targetChannel: UploadTargetChannel | null,
requireChannelAssignment = false,
selfAssign?: boolean,
cliHost?: { supaHost?: string, supaAnon?: string },
options?: Pick<OptionsUpload, 'supaHost' | 'supaAnon' | 'stable'>,
): Promise<boolean> {
const canPromoteTargetChannel = targetChannel !== null
&& await hasCliPermission(supabase, apikey, 'channel.promote_bundle', { appId: appid, channelId: targetChannel.id })
Expand All @@ -1122,42 +1127,29 @@ async function setVersionInChannel(

if (targetChannel && canPromoteTargetChannel) {
const versionId = await getVersionIdForChannelUpdate(supabase, apikey, appid, bundle)
if (selfAssign) {
const canUpdateChannelSettings = await hasCliPermission(supabase, apikey, 'channel.update_settings', { appId: appid, channelId: targetChannel.id })
if (!canUpdateChannelSettings) {
log.warn('Cannot enable device self-assign because this API key lacks channel.update_settings')
return promoteExistingChannel(apikey, appid, versionId, targetChannel, localConfig, displayBundleUrl, cliHost)
}
}
const promoted = await promoteExistingChannel(apikey, appid, versionId, targetChannel, localConfig, displayBundleUrl, options)
if (!promoted)
return false

if (!selfAssign)
return promoteExistingChannel(apikey, appid, versionId, targetChannel, localConfig, displayBundleUrl, cliHost)
return true

const canUpdateChannelSettings = await hasCliPermission(supabase, apikey, 'channel.update_settings', { appId: appid, channelId: targetChannel.id })
if (!canUpdateChannelSettings) {
log.warn('Cannot enable device self-assign because this API key lacks channel.update_settings')
return true
}

const { error: dbError3, data } = await updateOrCreateChannel(supabase, {
const { error: dbError3 } = await updateOrCreateChannel(supabase, {
name: channel,
app_id: appid,
created_by: userId,
version: versionId,
owner_org: orgId,
...(selfAssign ? { allow_device_self_set: true } : {}),
allow_device_self_set: true,
})
if (dbError3) {
await uploadFailIfChannelError(dbError3, () => `Cannot set channel because this API key does not have the required RBAC permission. ${formatError(dbError3)}`)
}
if (data?.id) {
const bundleUrl = `${localConfig.hostWeb}/app/${appid}/channel/${data.id}`
if (targetChannel.rollout_enabled && targetChannel.rollout_version != null) {
log.warn('This channel has an active progressive rollout. Linking this bundle as the stable version resets that rollout, so devices receive the new bundle instead of the previous rollout target.')
}
else if (data.public) {
log.info('Your update is now available in your public channel 🎉')
}
else {
log.info(`Link device to this bundle to try it: ${bundleUrl}`)
}
if (displayBundleUrl)
log.info(`Bundle url: ${bundleUrl}`)
}
return true
}

Expand All @@ -1173,8 +1165,8 @@ async function setVersionInChannel(
version: bundle,
...(selfAssign ? { allow_device_self_set: true } : {}),
},
supaHost: cliHost?.supaHost,
supaAnon: cliHost?.supaAnon,
supaHost: options?.supaHost,
supaAnon: options?.supaAnon,
})
if (error) {
await uploadFailIfChannelError(error, async () => `Cannot create channel and set its bundle because this API key does not have the required RBAC permission. ${await formatFunctionInvokeError(error)}`)
Expand Down Expand Up @@ -2178,6 +2170,9 @@ export function checkValidOptions(options: OptionsUpload) {
if (options.rolloutCacheTtlSeconds != null && (!Number.isInteger(options.rolloutCacheTtlSeconds) || options.rolloutCacheTtlSeconds < 60 || options.rolloutCacheTtlSeconds > 31536000)) {
uploadFail('Rollout cache TTL seconds must be between 60 and 31536000')
}
if (options.stable === true && hasUploadRollout) {
uploadFail('You cannot use --stable together with --rollout, --rollout-percentage-bps, or --rollout-advance')
}
if (hasUploadRollout && options.dryUpload) {
uploadFail('You cannot use --rollout or --rollout-advance with --dry-upload because dry upload does not update channels')
}
Expand Down
1 change: 1 addition & 0 deletions cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ Example: npx @capgo/cli@latest bundle upload com.example.app --path ./dist --cha
.option('--rollout <rollout>', `Set the uploaded bundle as this channel's rollout target at a percentage from 0 to 100`, value => Number.parseFloat(value))
.option('--rollout-percentage-bps <rolloutPercentageBps>', `Set the uploaded bundle rollout percentage in basis points from 0 to 10000`, value => Number.parseInt(value, 10))
.option('--rollout-advance', `Promote the current rollout target to stable, then set the uploaded bundle as the new rollout. Reuses the previous percentage unless --rollout or --rollout-percentage-bps is also set`)
.option('--stable', `Assign the uploaded bundle to the channel stable version instead of the rollout target when progressive rollout is configured`)
.option('--rollout-cache-ttl-seconds <rolloutCacheTtlSeconds>', `Cloudflare rollout decision cache TTL in seconds`, value => Number.parseInt(value, 10))
.option('-e, --external <url>', `Link to external URL instead of upload to Capgo Cloud`)
.option('--iv-session-key <key>', `Set the IV and session key for bundle URL external`)
Expand Down
1 change: 1 addition & 0 deletions cli/src/schemas/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const optionsUploadSchema = optionsBaseSchema.extend({
rollout: z.number().min(0).max(100).optional(),
rolloutPercentageBps: z.number().int().min(0).max(10000).optional(),
rolloutAdvance: z.boolean().optional(),
stable: z.boolean().optional(),
Comment thread
cursor[bot] marked this conversation as resolved.
rolloutCacheTtlSeconds: z.number().int().min(60).max(31536000).optional(),
displayIvSession: z.boolean().optional(),
external: z.string().optional(),
Expand Down
11 changes: 11 additions & 0 deletions cli/test/test-fail-on-incompatible.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,17 @@ test('--rollout-advance alone does not trigger a dry-upload conflict', () => {
assert.doesNotThrow(() => checkValidOptions({ rolloutAdvance: true }))
})

test('--stable with rollout options is rejected', () => {
assert.throws(
() => checkValidOptions({ stable: true, rolloutAdvance: true }),
(error) => {
assert.ok(error instanceof Error, 'expected an Error to be thrown')
assert.match(error.message, /--stable/, 'message should mention --stable')
return true
},
)
})

console.log('\n🧪 Testing rejectOrAcceptIncompatibleChannelBundle...\n')

test('channel set throws when incompatible and not accepted', () => {
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
33 changes: 32 additions & 1 deletion messages/en.context.json
Original file line number Diff line number Diff line change
Expand Up @@ -3398,5 +3398,36 @@
"your-api-key": "Used in Capgo web console areas: services. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"your-role-in-org": "Used in Capgo web console areas: pages. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"your-usage": "Used in Capgo web console areas: pages/settings/organization. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"zip-bundle": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged."
"zip-bundle": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"change-rollout-target": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"complete-rollout": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"rollback-rollout": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"enable-rollout": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"disable-rollout": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"pause-rollout": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"resume-rollout": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"apply-rollout-percentage": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"rollout-stable-tooltip": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"rollout-target-tooltip": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"rollout-percentage-tooltip": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"rollout-cache-ttl-tooltip": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"rollout-promote-confirm-title": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"rollout-promote-confirm-description": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"rollout-promote-confirm-action": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"rollout-rollback-confirm-title": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"rollout-rollback-confirm-description": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"rollout-rollback-confirm-action": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"rollout-disable-confirm-title": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"rollout-disable-confirm-description": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"rollout-disable-confirm-action": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"rollout-pause-confirm-title": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"rollout-pause-confirm-description": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"rollout-resume-confirm-title": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"rollout-resume-confirm-description": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"channel-bundle-assign-rollout-title": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"channel-bundle-assign-rollout-hint": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"channel-bundle-assign-auto": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"channel-bundle-assign-rollout": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"channel-bundle-assign-stable": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.",
"rollout-requires-stable-bundle": "Used in Capgo web console areas: pages/app. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged."
}
37 changes: 34 additions & 3 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3140,14 +3140,15 @@
"translation-unavailable": "This language is not available right now.",
"progressive-rollout": "Progressive rollout",
"set-rollout-target": "Set target",
"rollout-target": "Target",
"change-rollout-target": "Change rollout target",
"rollout-target": "Rollout target",
"stable-fallback": "Stable fallback",
"channel-version-with-rollout": "{fallback} · serving {target}",
"rollout-settings-help": "Selected devices receive this bundle. Everyone else stays on the stable fallback. Linking a new channel bundle resets this rollout unless you set a new target at the same time.",
"rollout-settings-help": "Selected devices receive the rollout target. Everyone else stays on stable. New uploads and bundle links use the rollout path by default while progressive rollout is configured.",
"rollout-delivery-all-title": "All devices receive {target}",
"rollout-delivery-split-title": "{percent} of devices receive {target}",
"rollout-delivery-fallback-title": "Devices receive {fallback}",
"rollout-delivery-upload-hint": "Linking a new channel bundle resets this rollout so devices receive that bundle. Use --rollout to keep serving a percentage of devices.",
"rollout-delivery-upload-hint": "New uploads and bundle links on this channel go to the rollout target by default. Stable stays unchanged unless you choose to replace it.",
"rollout-delivery-paused-hint": "Rollout is paused, so target {target} is not served.",
"rollout-delivery-zero-hint": "Rollout is 0%, so target {target} is not served.",
"rollout-percentage": "Rollout percentage",
Expand All @@ -3158,6 +3159,36 @@
"pause": "Pause",
"resume": "Resume",
"promote": "Promote",
"complete-rollout": "Complete rollout",
"rollback-rollout": "Rollback rollout",
"enable-rollout": "Enable rollout",
"disable-rollout": "Disable rollout",
"pause-rollout": "Pause rollout",
"resume-rollout": "Resume rollout",
"apply-rollout-percentage": "Apply percentage",
"rollout-stable-tooltip": "Stable is the fallback bundle. Devices not selected for rollout keep receiving this version.",
"rollout-target-tooltip": "Rollout target is the bundle gradually released to a percentage of devices. Everyone else stays on stable.",
"rollout-percentage-tooltip": "Share of devices that receive the rollout target. The rest stay on stable until you complete or raise the percentage.",
"rollout-cache-ttl-tooltip": "How long Cloudflare caches each device's rollout decision. Lower values react faster to percentage changes.",
"rollout-promote-confirm-title": "Complete progressive rollout?",
"rollout-promote-confirm-description": "This makes {target} the new stable bundle for everyone and ends progressive rollout. Stable {stable} is replaced. Rollout percentage resets to 0% and cannot be undone from this screen.",
"rollout-promote-confirm-action": "Complete rollout",
"rollout-rollback-confirm-title": "Rollback progressive rollout?",
"rollout-rollback-confirm-description": "This removes rollout target {target}. Devices return to stable {stable} only. The rollout target bundle is unlinked from this channel.",
"rollout-rollback-confirm-action": "Rollback rollout",
"rollout-disable-confirm-title": "Disable progressive rollout?",
"rollout-disable-confirm-description": "This turns off rollout and removes rollout target {target} from this channel. Devices keep stable {stable}.",
"rollout-disable-confirm-action": "Disable rollout",
"rollout-pause-confirm-title": "Pause progressive rollout?",
"rollout-pause-confirm-description": "New devices stop receiving rollout target {target}. Devices already on the rollout keep it until cache expires. Stable {stable} is unchanged.",
"rollout-resume-confirm-title": "Resume progressive rollout?",
"rollout-resume-confirm-description": "Rollout target {target} is served again to {percent} of devices. Stable {stable} stays the fallback.",
"channel-bundle-assign-rollout-title": "This channel uses progressive rollout",
"channel-bundle-assign-rollout-hint": "Choose where this bundle should land. Auto uses rollout when progressive rollout is configured.",
"channel-bundle-assign-auto": "Auto (recommended)",
"channel-bundle-assign-rollout": "Rollout target",
"channel-bundle-assign-stable": "Replace stable",
"rollout-requires-stable-bundle": "Set a stable bundle on this channel before using progressive rollout.",
"notify": "Notify",
"auto-pause": "Auto-pause",
"failure-rate-bps": "Failure bps",
Expand Down
Loading
Loading