Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
3396652
feat: add random sticky rollouts
riderx May 6, 2026
6fd678a
fix: address rollout sonar findings
riderx May 6, 2026
5efc9de
fix: satisfy backend lint
riderx May 6, 2026
29e0d81
fix: preserve special versions during rollout retention
riderx May 6, 2026
3e5a502
fix: address rollout review feedback
riderx May 6, 2026
041b8df
fix: keep disabled rollout targets sticky
riderx May 6, 2026
b0e6783
fix: scope rollout auto-pause stats
riderx May 6, 2026
811463d
fix: address rollout api review items
riderx May 6, 2026
82fa4e6
fix: address rollout stats review
riderx May 6, 2026
88a7cf0
fix: reject conflicting rollout flags
riderx May 6, 2026
d9a83db
fix: address rollout review batch
riderx May 6, 2026
64a5275
fix: validate rollout bundle compatibility
riderx May 6, 2026
2d539c3
fix: enforce disabled rollout decisions
riderx May 6, 2026
6cc346d
fix: preserve paused rollout fast path
riderx May 6, 2026
2e8ebad
fix: align rollout failure stats cohort
riderx May 6, 2026
d8d7da3
feat(ui): expose rollout policy controls
riderx May 7, 2026
ce5df96
fix: verify stats rollout channel attribution
riderx May 7, 2026
087d184
Merge remote-tracking branch 'origin/main' into codex/random-sticky-r…
riderx May 7, 2026
0a9947b
docs: add rollout ui screenshots
riderx May 7, 2026
4155f28
chore: move rollout migration after main
riderx May 7, 2026
06d83f0
Merge remote-tracking branch 'origin/main' into codex/random-sticky-r…
riderx May 7, 2026
64c7e70
fix(api): reject deleted rollout version ids
riderx May 7, 2026
bfe3871
fix(db): restore cli app listing wrapper
riderx May 7, 2026
17a09d6
fix(stats): order fallback channel attribution
riderx May 7, 2026
c735329
feat(cli): support rollout upload option
riderx May 8, 2026
d9e82c7
Merge remote-tracking branch 'origin/main' into codex/random-sticky-r…
riderx May 8, 2026
24f85ca
fix(ci): refresh rollout migration order
riderx May 8, 2026
0e3d280
fix(db): keep app list rpc removed
riderx May 8, 2026
eb112ef
fix(api): harden rollout selection
riderx May 8, 2026
cba34d9
test(api): scope apikey expiration fixture
riderx May 8, 2026
8d27c7d
test(api): skip password policy db suites on cloudflare
riderx May 8, 2026
6f07509
fix(api): address rollout review followups
riderx May 8, 2026
88412d8
fix(frontend): simplify rollout controls
riderx May 10, 2026
19095cf
fix(api): key rollout stats by channel id
riderx May 10, 2026
6d3174d
Merge remote-tracking branch 'origin/main' into codex/random-sticky-r…
riderx May 10, 2026
10e77a9
fix(ci): correct config builder typo
riderx May 10, 2026
d0823da
test(api): skip db-heavy suites in cloudflare mode
riderx May 10, 2026
ec7893b
test(api): skip rpc permission suite in cloudflare mode
riderx May 10, 2026
c73d8c9
docs(pr): refresh rollout ui screenshots
riderx May 10, 2026
3ff14e7
fix(frontend): polish rollout screenshot view
riderx May 10, 2026
3f46412
fix(frontend): hide rollout controls when disabled
riderx May 10, 2026
67b7582
fix(api): guard rollout target changes
riderx May 10, 2026
593a982
fix(frontend): remove channel build preview icon
riderx May 10, 2026
118e84c
Merge remote-tracking branch 'origin/main' into codex/random-sticky-r…
riderx May 10, 2026
2705edd
fix(db): move rollout migration after main
riderx May 10, 2026
1ad34ee
Merge remote-tracking branch 'origin/main' into codex/random-sticky-r…
riderx May 10, 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
4 changes: 2 additions & 2 deletions cli/src/api/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ export function findBundleIdByChannelName(supabase: SupabaseClient<Database>, ap
.from('channels')
.select(`
id,
version (id, name)
version:app_versions!channels_version_fkey(id, name)
`)
.eq('app_id', appId)
.eq('name', name)
Expand Down Expand Up @@ -263,7 +263,7 @@ export async function getActiveChannels(
created_at,
created_by,
app_id,
version (id, name)
version:app_versions!channels_version_fkey(id, name)
`)
.eq('app_id', appid)
.order('created_at', { ascending: false })
Expand Down
4 changes: 2 additions & 2 deletions cli/src/bundle/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ async function verifyCompatibility(supabase: SupabaseType, pm: pmType, options:

const { data: channelData, error: channelError } = await supabase
.from('channels')
.select('disable_auto_update, version ( min_update_version, native_packages )')
.select('disable_auto_update, version:app_versions!channels_version_fkey( min_update_version, native_packages )')
.eq('name', channel)
.eq('app_id', appid)
.maybeSingle()
Expand Down Expand Up @@ -616,7 +616,7 @@ type LinkedChannelVersion = {
async function getLinkedBundleOnChannel(supabase: SupabaseType, appid: string, channel: string): Promise<LinkedChannelVersion> {
const { data, error } = await supabase
.from('channels')
.select('version ( id, name, deleted )')
.select('version:app_versions!channels_version_fkey( id, name, deleted )')
.eq('app_id', appid)
.eq('name', channel)

Expand Down
2 changes: 1 addition & 1 deletion cli/src/channel/currentBundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export async function currentBundleInternal(channel: string, appId: string, opti

const { data: supabaseChannel, error } = await supabase
.from('channels')
.select('version ( name )')
.select('version:app_versions!channels_version_fkey( name )')
.eq('name', channel)
.eq('app_id', appId)
.limit(1)
Expand Down
161 changes: 160 additions & 1 deletion cli/src/channel/set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,24 @@ export type { OptionsSetChannel } from '../schemas/channel'

const disableAutoUpdatesPossibleOptions = ['major', 'minor', 'metadata', 'patch', 'none']

function assertIntegerInRange(value: number, label: string, min: number, max: number) {
if (!Number.isFinite(value) || !Number.isInteger(value) || value < min || value > max)
throw new Error(`${label} must be an integer between ${min} and ${max}`)
}

function assertOptionalIntegerInRange(value: number | null | undefined, label: string, min: number, max: number) {
if (value == null)
return
assertIntegerInRange(value, label, min, max)
}

function assertOptionalConfidence(value: number | undefined) {
if (value == null)
return
if (!Number.isFinite(value) || value <= 0 || value >= 1)
throw new Error('Auto-pause confidence must be a number greater than 0 and less than 1')
}

export async function setChannelInternal(channel: string, appId: string, options: OptionsSetChannel, silent = false) {
if (!silent)
intro('Set channel')
Expand Down Expand Up @@ -97,6 +115,25 @@ export async function setChannelInternal(channel: string, appId: string, options
emulator,
device,
prod,
rolloutBundle,
rolloutPercentage,
rolloutPercentageBps,
rolloutEnable,
rolloutDisable,
rolloutPause,
rolloutResume,
rolloutRollback,
rolloutPromote,
rolloutCacheTtlSeconds,
autoPauseEnabled,
autoPauseDisabled,
autoPauseWindowMinutes,
autoPauseFailureRateBps,
autoPauseConfidence,
autoPauseMinAttempts,
autoPauseMinFailures,
autoPauseAction,
autoPauseCooldownMinutes,
} = options

if (latest && bundle) {
Expand Down Expand Up @@ -131,6 +168,25 @@ export async function setChannelInternal(channel: string, appId: string, options
&& device == null
&& prod == null
&& disableAutoUpdate == null
&& rolloutBundle == null
&& rolloutPercentage == null
&& rolloutPercentageBps == null
&& rolloutEnable == null
&& rolloutDisable == null
&& rolloutPause == null
&& rolloutResume == null
&& rolloutRollback == null
&& rolloutPromote == null
&& rolloutCacheTtlSeconds == null
&& autoPauseEnabled == null
&& autoPauseDisabled == null
&& autoPauseWindowMinutes == null
&& autoPauseFailureRateBps === undefined
&& autoPauseConfidence == null
&& autoPauseMinAttempts === undefined
&& autoPauseMinFailures === undefined
&& autoPauseAction == null
&& autoPauseCooldownMinutes == null
) {
if (!silent)
log.error('Missing argument, you need to provide a option to set')
Expand All @@ -147,7 +203,7 @@ export async function setChannelInternal(channel: string, appId: string, options
version: undefined as any,
}

const { error: channelError } = await supabase
const { data: existingChannel, error: channelError } = await supabase
.from('channels')
.select()
.eq('app_id', appId)
Expand All @@ -164,6 +220,25 @@ export async function setChannelInternal(channel: string, appId: string, options
? (extConfig?.config?.plugins?.CapacitorUpdater?.version || getBundleVersion('', options.packageJson))
: bundle

async function findRemoteBundle(versionName: string) {
const { data, error: vError } = await supabase
.from('app_versions')
.select()
.eq('app_id', appId)
.eq('name', versionName)
.eq('user_id', userId)
.eq('deleted', false)
.single()

if (vError || !data) {
if (!silent)
log.error(`Cannot find version ${versionName}`)
throw new Error(`Cannot find version ${versionName}`)
}

return data
}

if (resolvedBundleVersion != null) {
const { data, error: vError } = await supabase
.from('app_versions')
Expand Down Expand Up @@ -259,6 +334,90 @@ export async function setChannelInternal(channel: string, appId: string, options
channelPayload.version = data.id
}

if (rolloutBundle != null) {
const data = await findRemoteBundle(rolloutBundle)
channelPayload.rollout_version = data.id
Comment thread
riderx marked this conversation as resolved.
Comment thread
riderx marked this conversation as resolved.
if (rolloutEnable == null)
channelPayload.rollout_enabled = true
if (!silent)
log.info(`Set ${appId} channel: ${channel} rollout target to @${rolloutBundle}`)
}

if (rolloutPercentage != null) {
if (!Number.isFinite(rolloutPercentage) || rolloutPercentage < 0 || rolloutPercentage > 100)
throw new Error('Rollout percentage must be between 0 and 100')
}
const finalRolloutPercentageBps = rolloutPercentageBps ?? (rolloutPercentage == null ? undefined : Math.round(rolloutPercentage * 100))
if (finalRolloutPercentageBps != null) {
assertIntegerInRange(finalRolloutPercentageBps, 'Rollout percentage basis points', 0, 10000)
channelPayload.rollout_percentage_bps = finalRolloutPercentageBps
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (rolloutEnable != null)
channelPayload.rollout_enabled = !!rolloutEnable
if (rolloutDisable)
channelPayload.rollout_enabled = false

if (rolloutPause) {
channelPayload.rollout_paused_at = new Date().toISOString()
channelPayload.rollout_pause_reason = 'Paused from CLI'
}

if (rolloutResume) {
channelPayload.rollout_paused_at = null
channelPayload.rollout_pause_reason = null
}

if (rolloutRollback) {
channelPayload.rollout_version = null
channelPayload.rollout_enabled = false
channelPayload.rollout_percentage_bps = 0
channelPayload.rollout_paused_at = null
channelPayload.rollout_pause_reason = null
}

if (rolloutPromote) {
const rolloutVersion = channelPayload.rollout_version ?? existingChannel?.rollout_version
if (!rolloutVersion)
throw new Error('Cannot promote rollout without a rollout target')
channelPayload.version = rolloutVersion
channelPayload.rollout_version = null
channelPayload.rollout_enabled = false
channelPayload.rollout_percentage_bps = 0
channelPayload.rollout_paused_at = null
channelPayload.rollout_pause_reason = null
}

assertOptionalIntegerInRange(rolloutCacheTtlSeconds, 'Rollout cache TTL seconds', 60, 31536000)
assertOptionalIntegerInRange(autoPauseWindowMinutes, 'Auto-pause window minutes', 1, 10080)
assertOptionalIntegerInRange(autoPauseFailureRateBps, 'Auto-pause failure rate basis points', 0, 10000)
assertOptionalConfidence(autoPauseConfidence)
assertOptionalIntegerInRange(autoPauseMinAttempts, 'Auto-pause minimum attempts', 0, Number.MAX_SAFE_INTEGER)
assertOptionalIntegerInRange(autoPauseMinFailures, 'Auto-pause minimum failures', 0, Number.MAX_SAFE_INTEGER)
assertOptionalIntegerInRange(autoPauseCooldownMinutes, 'Auto-pause cooldown minutes', 0, 10080)

if (rolloutCacheTtlSeconds != null)
channelPayload.rollout_cache_ttl_seconds = rolloutCacheTtlSeconds

if (autoPauseEnabled != null)
channelPayload.auto_pause_enabled = !!autoPauseEnabled
if (autoPauseDisabled)
channelPayload.auto_pause_enabled = false
if (autoPauseWindowMinutes != null)
channelPayload.auto_pause_window_minutes = autoPauseWindowMinutes
if (autoPauseFailureRateBps !== undefined)
channelPayload.auto_pause_failure_rate_bps = autoPauseFailureRateBps
if (autoPauseConfidence != null)
channelPayload.auto_pause_confidence = autoPauseConfidence as any
if (autoPauseMinAttempts !== undefined)
channelPayload.auto_pause_min_attempts = autoPauseMinAttempts
if (autoPauseMinFailures !== undefined)
channelPayload.auto_pause_min_failures = autoPauseMinFailures
if (autoPauseAction != null)
channelPayload.auto_pause_action = autoPauseAction
if (autoPauseCooldownMinutes != null)
channelPayload.auto_pause_cooldown_minutes = autoPauseCooldownMinutes

if (state != null) {
if (state !== 'normal' && state !== 'default') {
if (!silent)
Expand Down
19 changes: 19 additions & 0 deletions cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,25 @@ Example: npx @capgo/cli@latest channel set production com.example.app --bundle 1
.option('--self-assign', `Allow device to self-assign to this channel`)
.option('--no-self-assign', `Disable devices to self-assign to this channel`)
.option('--disable-auto-update <disableAutoUpdate>', `Block updates by type: major, minor, metadata, patch, or none (allows all)`)
.option('--rollout-bundle <rolloutBundle>', `Bundle version to release gradually on this channel`)
.option('--rollout-percentage <rolloutPercentage>', `Rollout percentage from 0 to 100`, value => Number.parseFloat(value))
.option('--rollout-percentage-bps <rolloutPercentageBps>', `Rollout percentage in basis points from 0 to 10000`, value => Number.parseInt(value, 10))
.option('--rollout-enable', `Enable the configured rollout`)
.option('--rollout-disable', `Disable the configured rollout`)
.option('--rollout-pause', `Pause rollout exposure without rolling back selected devices`)
.option('--rollout-resume', `Resume a paused rollout`)
.option('--rollout-rollback', `Clear rollout state and return devices to stable`)
.option('--rollout-promote', `Promote rollout target to stable and clear rollout state`)
.option('--rollout-cache-ttl-seconds <rolloutCacheTtlSeconds>', `Cloudflare rollout decision cache TTL in seconds`, value => Number.parseInt(value, 10))
.option('--auto-pause-enabled', `Enable rollout auto-pause policy`)
.option('--auto-pause-disabled', `Disable rollout auto-pause policy`)
.option('--auto-pause-window-minutes <autoPauseWindowMinutes>', `Stats window for rollout auto-pause`, value => Number.parseInt(value, 10))
.option('--auto-pause-failure-rate-bps <autoPauseFailureRateBps>', `Failure-rate threshold in basis points`, value => Number.parseInt(value, 10))
.option('--auto-pause-confidence <autoPauseConfidence>', `Confidence level between 0 and 1`, value => Number.parseFloat(value))
.option('--auto-pause-min-attempts <autoPauseMinAttempts>', `Minimum install plus fail attempts before auto-pause can trigger`, value => Number.parseInt(value, 10))
.option('--auto-pause-min-failures <autoPauseMinFailures>', `Minimum failures before auto-pause can trigger`, value => Number.parseInt(value, 10))
.option('--auto-pause-action <autoPauseAction>', `Auto-pause action: pause, rollback, or notify`)
.option('--auto-pause-cooldown-minutes <autoPauseCooldownMinutes>', `Cooldown before auto-pause can trigger again`, value => Number.parseInt(value, 10))
.option('--dev', `Allow sending update to development devices`)
.option('--no-dev', `Disable sending update to development devices`)
.option('--prod', `Allow sending update to production devices`)
Expand Down
25 changes: 22 additions & 3 deletions cli/src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { z } from 'zod'
import pack from '../../package.json'
import { addAppOptionsSchema, cleanupOptionsSchema, getStatsOptionsSchema, requestBuildOptionsSchema, starAllRepositoriesOptionsSchema, starRepoOptionsSchema, updateAppOptionsSchema, updateChannelOptionsSchema, uploadOptionsSchema } from '../schemas/sdk'
import { addAppOptionsSchema, cleanupOptionsSchema, getStatsOptionsSchema, requestBuildOptionsSchema, starAllRepositoriesOptionsSchema, starRepoOptionsSchema, updateAppOptionsSchema, updateChannelOptionsBaseSchema, uploadOptionsSchema } from '../schemas/sdk'
import { CapgoSDK } from '../sdk'
import { findSavedKey } from '../utils'

Expand Down Expand Up @@ -334,8 +334,8 @@ export async function startMcpServer(): Promise<void> {
server.tool(
'capgo_update_channel',
'Update channel settings including linked bundle and targeting options',
updateChannelOptionsSchema.pick({ appId: true, channelId: true, bundle: true, state: true, downgrade: true, ios: true, android: true, selfAssign: true, disableAutoUpdate: true, dev: true, emulator: true, device: true, prod: true }).shape,
async ({ appId, channelId, bundle, state, downgrade, ios, android, selfAssign, disableAutoUpdate, dev, emulator, device, prod }) => {
updateChannelOptionsBaseSchema.pick({ appId: true, channelId: true, bundle: true, state: true, downgrade: true, ios: true, android: true, selfAssign: true, disableAutoUpdate: true, dev: true, emulator: true, device: true, prod: true, rolloutBundle: true, rolloutPercentage: true, rolloutPercentageBps: true, rolloutEnable: true, rolloutDisable: true, rolloutPause: true, rolloutResume: true, rolloutRollback: true, rolloutPromote: true, rolloutCacheTtlSeconds: true, autoPauseEnabled: true, autoPauseDisabled: true, autoPauseWindowMinutes: true, autoPauseFailureRateBps: true, autoPauseConfidence: true, autoPauseMinAttempts: true, autoPauseMinFailures: true, autoPauseAction: true, autoPauseCooldownMinutes: true }).shape,
Comment thread
riderx marked this conversation as resolved.
async ({ appId, channelId, bundle, state, downgrade, ios, android, selfAssign, disableAutoUpdate, dev, emulator, device, prod, rolloutBundle, rolloutPercentage, rolloutPercentageBps, rolloutEnable, rolloutDisable, rolloutPause, rolloutResume, rolloutRollback, rolloutPromote, rolloutCacheTtlSeconds, autoPauseEnabled, autoPauseDisabled, autoPauseWindowMinutes, autoPauseFailureRateBps, autoPauseConfidence, autoPauseMinAttempts, autoPauseMinFailures, autoPauseAction, autoPauseCooldownMinutes }) => {
const result = await sdk.updateChannel({
appId,
channelId,
Expand All @@ -350,6 +350,25 @@ export async function startMcpServer(): Promise<void> {
emulator,
device,
prod,
rolloutBundle,
rolloutPercentage,
rolloutPercentageBps,
rolloutEnable,
rolloutDisable,
rolloutPause,
rolloutResume,
rolloutRollback,
rolloutPromote,
rolloutCacheTtlSeconds,
autoPauseEnabled,
autoPauseDisabled,
autoPauseWindowMinutes,
autoPauseFailureRateBps,
autoPauseConfidence,
autoPauseMinAttempts,
autoPauseMinFailures,
autoPauseAction,
autoPauseCooldownMinutes,
})
if (!result.success) {
return formatMcpError(result)
Expand Down
38 changes: 38 additions & 0 deletions cli/src/schemas/channel.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
import { z } from 'zod'
import { optionsBaseSchema } from './base'

function rejectConflictingBooleanGroup<T extends Record<string, unknown>>(value: T, ctx: z.RefinementCtx, keys: Array<keyof T>) {
const selected = keys.filter(key => value[key] === true)
if (selected.length < 2)
return

const first = String(selected[0])
for (const key of selected.slice(1)) {
const current = String(key)
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [current],
message: `"${first}" and "${current}" cannot be used together`,
})
}
}
// ============================================================================
// Channel Data Schema
// ============================================================================
Expand Down Expand Up @@ -64,6 +79,29 @@ export const optionsSetChannelSchema = optionsBaseSchema.extend({
prod: z.boolean().optional(),
packageJson: z.string().optional(),
ignoreMetadataCheck: z.boolean().optional(),
rolloutBundle: z.string().optional(),
rolloutPercentage: z.number().finite().min(0).max(100).optional(),
rolloutPercentageBps: z.number().int().min(0).max(10000).optional(),
rolloutEnable: z.boolean().optional(),
rolloutDisable: z.boolean().optional(),
rolloutPause: z.boolean().optional(),
rolloutResume: z.boolean().optional(),
rolloutRollback: z.boolean().optional(),
rolloutPromote: z.boolean().optional(),
rolloutCacheTtlSeconds: z.number().int().min(60).max(31536000).optional(),
autoPauseEnabled: z.boolean().optional(),
autoPauseDisabled: z.boolean().optional(),
autoPauseWindowMinutes: z.number().int().min(1).max(10080).optional(),
autoPauseFailureRateBps: z.number().int().min(0).max(10000).nullable().optional(),
autoPauseConfidence: z.number().finite().gt(0).lt(1).optional(),
autoPauseMinAttempts: z.number().int().min(0).nullable().optional(),
autoPauseMinFailures: z.number().int().min(0).nullable().optional(),
autoPauseAction: z.enum(['pause', 'rollback', 'notify']).optional(),
autoPauseCooldownMinutes: z.number().int().min(0).max(10080).optional(),
}).superRefine((value, ctx) => {
rejectConflictingBooleanGroup(value, ctx, ['rolloutEnable', 'rolloutDisable'])
rejectConflictingBooleanGroup(value, ctx, ['rolloutPause', 'rolloutResume', 'rolloutRollback', 'rolloutPromote'])
rejectConflictingBooleanGroup(value, ctx, ['autoPauseEnabled', 'autoPauseDisabled'])
})

export type OptionsSetChannel = z.infer<typeof optionsSetChannelSchema>
Loading
Loading