Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 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
723c73d
fix(frontend): simplify rollout controls
riderx May 17, 2026
d8237fe
fix(frontend): hide disabled rollout panel
riderx May 17, 2026
d1e656e
Merge remote-tracking branch 'origin/main' into codex/random-sticky-r…
riderx May 17, 2026
35b6c37
fix(cli): import createRequire in updater init
riderx May 17, 2026
fa4c977
fix(db): move rollout migration after main
riderx May 17, 2026
9ebbd84
fix(db): preserve deleted version cleanup guards
riderx May 17, 2026
aade999
fix(frontend): show rollout enable row
riderx May 17, 2026
320fe10
fix(rollout): guard linked bundles and auto pause
riderx May 18, 2026
878a6f2
Merge remote-tracking branch 'origin/main' into codex/random-sticky-r…
riderx May 18, 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
9 changes: 9 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,13 +242,22 @@ Capgo never inspects external content. Add encryption for trustless security.
npx @capgo/cli@latest bundle upload com.example.app --path ./dist --channel production
```

Upload directly as a progressive rollout target while keeping the channel's stable bundle as fallback:

```bash
npx @capgo/cli@latest bundle upload com.example.app --path ./dist --channel production --rollout 10
```

**Options:**

| Param | Type | Description |
| -------------- | ------------- | -------------------- |
| **-a** | <code>string</code> | API key to link to your account |
| **-p** | <code>string</code> | Path of the folder to upload, if not provided it will use the webDir set in capacitor.config |
| **-c** | <code>string</code> | Channel to link to |
| **--rollout** | <code>string</code> | Set the uploaded bundle as this channel's rollout target at a percentage from 0 to 100 |
| **--rollout-percentage-bps** | <code>string</code> | Set the uploaded bundle rollout percentage in basis points from 0 to 10000 |
| **--rollout-cache-ttl-seconds** | <code>string</code> | Cloudflare rollout decision cache TTL in seconds |
| **-e** | <code>string</code> | Link to external URL instead of upload to Capgo Cloud |
| **--iv-session-key** | <code>string</code> | Set the IV and session key for bundle URL external |
| **--s3-region** | <code>string</code> | Region for your S3 bucket |
Expand Down
4 changes: 4 additions & 0 deletions cli/skills/release-management/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Use this skill for OTA update workflows in Capgo Cloud.

- Alias: `u`
- Example: `npx @capgo/cli@latest bundle upload com.example.app --path ./dist --channel production`
- Progressive rollout example: `npx @capgo/cli@latest bundle upload com.example.app --path ./dist --channel production --rollout 10`
- Key behavior:
- Bundle version must be greater than `0.0.0` and unique.
- Deleted versions cannot be reused.
Expand All @@ -29,6 +30,9 @@ Use this skill for OTA update workflows in Capgo Cloud.
- Important options:
- `-p, --path <path>`
- `-c, --channel <channel>`
- `--rollout <percentage>`
- `--rollout-percentage-bps <basisPoints>`
- `--rollout-cache-ttl-seconds <seconds>`
- `-e, --external <url>`
- `--iv-session-key <key>`
- `-b, --bundle <bundle>`
Expand Down
28 changes: 20 additions & 8 deletions cli/src/api/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export async function checkVersionNotUsedInChannel(
.from('channels')
.select()
.eq('app_id', appid)
.eq('version', versionData.id)
.or(`version.eq.${versionData.id},rollout_version.eq.${versionData.id}`)

if (channelName)
query = query.eq('name', channelName)
Expand Down Expand Up @@ -66,14 +66,26 @@ export async function checkVersionNotUsedInChannel(
const s = silent ? null : spinner()
s?.start(`Unlinking channel ${channel.name}`)

const unknownVersion = await findUnknownVersion(supabase, appid, { silent })
if (!unknownVersion) {
s?.stop(`Cannot find unknown version for ${appid}`)
throw new Error(`Cannot find unknown version for ${appid}`)
const patch: Database['public']['Tables']['channels']['Update'] = {}
if (channel.version === versionData.id) {
const unknownVersion = await findUnknownVersion(supabase, appid, { silent })
if (!unknownVersion) {
s?.stop(`Cannot find unknown version for ${appid}`)
throw new Error(`Cannot find unknown version for ${appid}`)
}
patch.version = unknownVersion.id
}
if (channel.rollout_version === versionData.id) {
patch.rollout_version = null
patch.rollout_enabled = false
patch.rollout_percentage_bps = 0
patch.rollout_paused_at = null
patch.rollout_pause_reason = null
}

const { error: errorChannelUpdate } = await supabase
.from('channels')
.update({ version: unknownVersion.id })
.update(patch)
.eq('id', channel.id)

if (errorChannelUpdate) {
Expand Down Expand Up @@ -190,7 +202,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 @@ -255,7 +267,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
132 changes: 121 additions & 11 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 @@ -612,11 +612,36 @@ type LinkedChannelVersion = {
name: string
} | null

function getUploadRolloutPercentageBps(options: OptionsUpload) {
if (options.rollout == null && options.rolloutPercentageBps == null)
return undefined

if (options.rolloutPercentageBps != null)
return options.rolloutPercentageBps

return Math.round((options.rollout ?? 0) * 100)
}

function formatRolloutPercentage(bps: number) {
return `${Number((bps / 100).toFixed(2))}%`
}

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 })
.single()

if (!versionId)
uploadFail('Cannot get version id, cannot set channel')

return versionId
}

// It is really important that this function never terminates the program, it should always return.
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 Expand Up @@ -673,12 +698,7 @@ async function setVersionInChannel(
localConfig: localConfigType,
selfAssign?: boolean,
) {
const { data: versionId } = await supabase
.rpc('get_app_versions', { apikey, name_version: bundle, appid })
.single()

if (!versionId)
uploadFail('Cannot get version id, cannot set channel')
const versionId = await getVersionIdForChannelUpdate(supabase, apikey, appid, bundle)

const apiAccess = await hasCliPermission(supabase, apikey, 'app.create_channel', { appId: appid })

Expand Down Expand Up @@ -707,6 +727,72 @@ async function setVersionInChannel(
}
}

async function setRolloutVersionInChannel(
supabase: SupabaseType,
apikey: string,
displayBundleUrl: boolean,
bundle: string,
channel: string,
appid: string,
localConfig: localConfigType,
rolloutPercentageBps: number,
rolloutCacheTtlSeconds?: number,
selfAssign?: boolean,
) {
const versionId = await getVersionIdForChannelUpdate(supabase, apikey, appid, bundle)

const { data: apiAccess } = await supabase
.rpc('is_allowed_capgkey', { apikey, keymode: ['write', 'all'] })
.single()

if (!apiAccess) {
log.warn('The upload key is not allowed to set the rollout in the channel')
return
}

const { data: existingChannel, error: channelError } = await supabase
.from('channels')
.select('id, version')
.eq('app_id', appid)
.eq('name', channel)
.single()

if (channelError || !existingChannel) {
uploadFail(`Cannot set rollout, channel ${channel} must already exist with a stable bundle`)
}
if (!existingChannel.version) {
uploadFail(`Cannot set rollout, channel ${channel} needs a stable bundle before using progressive rollout`)
}

const channelPayload: Database['public']['Tables']['channels']['Update'] = {
rollout_version: versionId,
rollout_percentage_bps: rolloutPercentageBps,
rollout_enabled: true,
rollout_paused_at: null,
rollout_pause_reason: null,
...(selfAssign ? { allow_device_self_set: true } : {}),
}
if (rolloutCacheTtlSeconds != null)
channelPayload.rollout_cache_ttl_seconds = rolloutCacheTtlSeconds

const { error: rolloutError, data } = await supabase
.from('channels')
.update(channelPayload)
.eq('app_id', appid)
.eq('name', channel)
.select('id')
.single()

if (rolloutError)
uploadFail(`Cannot set rollout in channel ${formatError(rolloutError)}`)

const bundleUrl = `${localConfig.hostWeb}/app/${appid}/channel/${data.id}`
log.info(`Set ${appid} channel ${channel} rollout target to @${bundle} (${formatRolloutPercentage(rolloutPercentageBps)})`)

if (displayBundleUrl)
log.info(`Bundle url: ${bundleUrl}`)
}

export async function getDefaultUploadChannel(appId: string, supabase: SupabaseType, hostWeb: string) {
const { error, data } = await supabase.from('apps')
.select('default_upload_channel')
Expand Down Expand Up @@ -835,6 +921,7 @@ export async function uploadBundleInternal(preAppid: string, options: OptionsUpl

const defaultUploadChannel = options.channel ? null : await getDefaultUploadChannel(appid, supabase, localConfig.hostWeb)
const channel = options.channel || defaultUploadChannel || 'production'
const rolloutPercentageBps = getUploadRolloutPercentageBps(options)
if (options.verbose)
log.info(`[Verbose] Target channel: ${channel}`)

Expand Down Expand Up @@ -1237,9 +1324,16 @@ export async function uploadBundleInternal(preAppid: string, options: OptionsUpl
}

if (hasOrganizationPerm(permissions, OrganizationPerm.write)) {
if (options.verbose)
log.info(`[Verbose] Setting bundle ${bundle} to channel ${channel}...`)
await setVersionInChannel(supabase, apikey, !!options.bundleUrl, bundle, channel, userId, orgId, appid, localConfig, options.selfAssign)
if (rolloutPercentageBps != null) {
if (options.verbose)
log.info(`[Verbose] Setting bundle ${bundle} as rollout target for channel ${channel}...`)
await setRolloutVersionInChannel(supabase, apikey, !!options.bundleUrl, bundle, channel, appid, localConfig, rolloutPercentageBps, options.rolloutCacheTtlSeconds, options.selfAssign)
}
else {
if (options.verbose)
log.info(`[Verbose] Setting bundle ${bundle} to channel ${channel}...`)
await setVersionInChannel(supabase, apikey, !!options.bundleUrl, bundle, channel, userId, orgId, appid, localConfig, options.selfAssign)
}
if (options.verbose)
log.info(`[Verbose] Channel updated successfully`)

Expand Down Expand Up @@ -1329,6 +1423,7 @@ function checkValidOptions(options: OptionsUpload) {
const noKey = options.key === false
const forceCrc32 = options.forceCrc32Checksum === true
const hasEncryptionKey = (options.keyV2 || options.keyDataV2 || existsSync(baseKeyV2))
const hasUploadRollout = options.rollout != null || options.rolloutPercentageBps != null

if (options.ivSessionKey && !options.external) {
uploadFail('You need to provide an external url if you want to use the --iv-session-key option')
Expand Down Expand Up @@ -1371,6 +1466,21 @@ function checkValidOptions(options: OptionsUpload) {
if (forceCrc32 && hasEncryptionKey && !noKey) {
uploadFail('You cannot use --force-crc32-checksum when encryption is enabled. Remove the flag or disable encryption.')
}
if (options.rollout != null && (!Number.isFinite(options.rollout) || options.rollout < 0 || options.rollout > 100)) {
uploadFail('Rollout percentage must be between 0 and 100')
}
if (options.rolloutPercentageBps != null && (!Number.isInteger(options.rolloutPercentageBps) || options.rolloutPercentageBps < 0 || options.rolloutPercentageBps > 10000)) {
uploadFail('Rollout percentage basis points must be between 0 and 10000')
}
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 (hasUploadRollout && options.dryUpload) {
uploadFail('You cannot use --rollout with --dry-upload because dry upload does not update channels')
}
if (hasUploadRollout && options.deleteLinkedBundleOnUpload) {
uploadFail('You cannot use --rollout with --delete-linked-bundle-on-upload because rollout needs the stable channel bundle as fallback')
}
}

async function maybePromptStarCapgoRepo() {
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
Loading
Loading