tests/cicd-setup-guide.unit.test.ts
Rama main · réplica de lectura del repositorio original.
// @vitest-environment happy-dom
import { execFile } from 'node:child_process'
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { createServer } from 'node:http'
import { tmpdir } from 'node:os'
import { delimiter, dirname, join } from 'node:path'
import { env, execPath } from 'node:process'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import { lessThan, parse as parseSemver } from '@std/semver'
import { beforeEach, describe, expect, it } from 'vitest'
import { parse } from 'yaml'
import { autoBumpVersionBy } from '../cli/src/versionHelpers'
import {
buildCiJobs,
buildCiPipeline,
BUILDER_PREVIEW_SCOPE,
builderCredentialsPushCommand,
CI_ENCRYPTION_SECRET,
CI_PRODUCTS,
CI_PROVIDERS,
CI_RESTORE_KEY_COMMAND,
CI_STRATEGIES,
ciKeysFor,
ENCRYPTED_PREVIEW_SCOPE,
encryptionSecretCommand,
githubSecretCommand,
isCiChannelSupported,
loadCiGuideSelection,
normalizeCiBranch,
normalizeCiChannel,
normalizeCiChannels,
saveCiGuideSelection,
} from '../src/utils/cicdSetupGuide'
const appId = 'com.example.app'
const base = { appId, productionChannels: ['production'] } as const
const runFile = promisify(execFile)
describe('CI/CD guide pipelines', () => {
it.each(CI_PROVIDERS)('requires an existing production target for live updates on %s', (provider) => {
for (const strategy of CI_STRATEGIES) {
for (const productionChannels of [undefined, [], ['']]) {
expect(() => buildCiPipeline({ appId, product: 'ota', strategy, provider, productionChannels })).toThrow('An existing production release channel is required')
}
// Native builds do not publish to a channel.
expect(() => buildCiPipeline({ appId, product: 'builder', strategy, provider })).not.toThrow()
}
})
it('keeps an explicitly selected private target without widening release permissions', () => {
for (const strategy of ['main', 'pull_request'] as const) {
for (const provider of CI_PROVIDERS) {
const pipeline = buildCiPipeline({ appId, product: 'ota', strategy, provider, productionChannels: ['private-live'] })
expect(pipeline.keys.find(key => key.purpose === 'release')).toEqual({ secret: 'CAPGO_TOKEN', role: 'app_uploader', purpose: 'release' })
expect(pipeline.jobs.find(job => job.id === 'production')?.command).toContain('--channel private-live --auto-bump')
}
}
})
it('renders valid YAML for every product, strategy and provider', () => {
for (const product of CI_PRODUCTS) {
for (const strategy of CI_STRATEGIES) {
for (const provider of CI_PROVIDERS) {
const pipeline = buildCiPipeline({ ...base, product, strategy, provider })
expect(pipeline.content.endsWith('\n')).toBe(true)
expect(pipeline.content).not.toContain('__CAPGO_PR_NUMBER__')
expect(pipeline.content).not.toContain('--apikey')
if (provider === 'other') {
expect(pipeline.file).toBeNull()
expect(pipeline.language).toBe('shell')
continue
}
expect(pipeline.language).toBe('yaml')
expect(() => parse(pipeline.content), `${product}/${strategy}/${provider}`).not.toThrow()
if (provider === 'github')
expect(parse(pipeline.content).permissions).toEqual({ contents: 'read' })
}
}
}
})
it.each(CI_PRODUCTS.flatMap(product => CI_STRATEGIES.map(strategy => ({ product, strategy }))))('scopes GitHub token access for $product/$strategy', ({ product, strategy }) => {
const workflow = parse(buildCiPipeline({ ...base, product, strategy, provider: 'github' }).content)
expect(workflow.env).toBeUndefined()
for (const job of Object.values<{ env?: unknown, steps: { uses?: string, id?: string, with?: { 'persist-credentials'?: boolean }, env?: Record<string, string> }[] }>(workflow.jobs)) {
expect(job.env).toBeUndefined()
for (const checkout of job.steps.filter(step => step.uses === 'actions/checkout@v6'))
expect(checkout.with?.['persist-credentials']).toBe(false)
for (const step of job.steps) {
if (step.id === 'release_head')
expect(step.env).toEqual({ GH_TOKEN: '${{ github.token }}' })
else
expect(Object.values(step.env ?? {})).not.toContain('${{ github.token }}')
}
}
})
it('ships live updates from main to the default channel on GitHub', () => {
const pipeline = buildCiPipeline({ ...base, productionChannels: ['stable'], product: 'ota', strategy: 'main', provider: 'github' })
const workflow = parse(pipeline.content)
expect(pipeline.file).toBe('.github/workflows/capgo.yml')
expect(workflow.on).toEqual({ push: { branches: ['main'] } })
expect(pipeline.content).toContain("branches: ['main']")
const steps = workflow.jobs.production.steps
expect(workflow.jobs.production_build.steps.filter((step: { run?: string }) => step.run).slice(0, 2).map((step: { run: string }) => step.run)).toEqual(['npm ci', 'npm run build'])
expect(steps.at(-1).run).toBe('npx @capgo/cli@latest bundle upload com.example.app --channel stable --auto-bump patch --fail-on-incompatible')
expect(steps.some((step: { run?: string }) => step.run?.startsWith('npm ci'))).toBe(false)
expect(steps.some((step: { run?: string }) => step.run === 'npm run build')).toBe(false)
expect(steps.at(-1).env).toEqual({ CAPGO_TOKEN: '${{ secrets.CAPGO_TOKEN }}' })
expect(pipeline.keys).toEqual([{ secret: 'CAPGO_TOKEN', role: 'app_uploader', purpose: 'release' }])
})
it('rejects production and preproduction branches that normalize to the same name', () => {
for (const product of CI_PRODUCTS) {
expect(() => buildCiPipeline({ ...base, product, strategy: 'main_preprod', provider: 'github', productionBranch: ' main ', preprodBranch: 'main' })).toThrow('branches must be different')
}
})
it('keeps release credentials in provider scopes that can restrict branch access', () => {
for (const product of CI_PRODUCTS) {
const options = { ...base, product, strategy: 'pull_request' } as const
const githubPipeline = buildCiPipeline({ ...options, provider: 'github' })
const github = parse(githubPipeline.content)
for (const { id } of githubPipeline.jobs)
expect(github.jobs[id].environment, id).toBe(id.includes('preview') ? product === 'builder' ? BUILDER_PREVIEW_SCOPE : undefined : 'capgo-release')
const bitbucket = parse(buildCiPipeline({ ...options, provider: 'bitbucket', platforms: ['ios'] }).content)
expect(bitbucket.pipelines.branches.main[0].step.environment).toBe('capgo-release')
if (product === 'ota')
expect(bitbucket.pipelines['pull-requests']['**'][0].step.environment).toBe('capgo-preview')
else
expect(bitbucket.pipelines['pull-requests']['**'][1].stage.environment).toBe(BUILDER_PREVIEW_SCOPE)
const circleci = parse(buildCiPipeline({ ...options, provider: 'circleci' }).content)
for (const entry of circleci.workflows.capgo.jobs) {
const [id, config] = Object.entries<{ context?: string, type?: string }>(entry)[0]!
if (config.type === 'approval') {
expect(config.context).toBeUndefined()
continue
}
const previewScope = product === 'builder' ? BUILDER_PREVIEW_SCOPE : 'capgo-preview'
expect(config.context, id).toBe(id.includes('preview') ? previewScope : 'capgo-release')
}
const testerJobs = buildCiJobs({ ...options, strategy: 'main', provider: 'github' })
if (product === 'builder')
expect(testerJobs.every(job => job.env?.CAPGO_STORE_SUBMIT_REVIEW === 'false')).toBe(true)
}
})
it('routes main and preprod to their own channels with custom branch names', () => {
const jobs = buildCiJobs({ ...base, product: 'ota', strategy: 'main_preprod', provider: 'gitlab', productionBranch: 'master', preprodBranch: 'release/staging' })
expect(jobs.map(job => [job.trigger, job.command])).toEqual([
[{ kind: 'push', branch: 'master' }, expect.stringContaining('--channel production ')],
[{ kind: 'push', branch: 'release/staging' }, expect.stringContaining('--channel preprod ')],
])
const gitlab = parse(buildCiPipeline({ ...base, product: 'ota', strategy: 'main_preprod', provider: 'gitlab', productionBranch: 'master', preprodBranch: 'release/staging' }).content)
expect(gitlab['capgo-preprod'].rules).toEqual([{ if: '$CI_COMMIT_BRANCH == "release/staging"' }])
expect(ciKeysFor('ota', 'main_preprod')).toEqual([{ secret: 'CAPGO_TOKEN', role: 'app_developer', purpose: 'release' }])
})
it('keeps tester uploads distinct from every production default across providers', () => {
for (const provider of CI_PROVIDERS) {
const options = { ...base, product: 'ota', strategy: 'main_preprod', provider, productionChannels: ['ios', 'preprod'] } as const
expect(() => buildCiPipeline(options)).toThrow('different from every production default')
expect(() => buildCiPipeline({ ...options, preprodChannel: 'ios' })).toThrow('different from every production default')
for (const invalid of ['', ' a', 'a ', 'a,b', 'a\nb'])
expect(() => buildCiPipeline({ ...options, preprodChannel: invalid })).toThrow('supported by the CLI')
const pipeline = buildCiPipeline({ ...options, preprodChannel: 'ci-testers' })
expect(pipeline.jobs.find(job => job.id === 'production')?.command).toContain('--channel ios,preprod ')
expect(pipeline.jobs.find(job => job.id === 'preprod')?.command).toContain('--channel ci-testers ')
// Branch-only releases and native builds do not use this tester channel.
expect(() => buildCiPipeline({ ...options, strategy: 'main' })).not.toThrow()
expect(() => buildCiPipeline({ ...options, product: 'builder' })).not.toThrow()
}
})
it('uses a separate preview key for pull requests and cleans up on GitHub', () => {
const pipeline = buildCiPipeline({ ...base, product: 'ota', strategy: 'pull_request', provider: 'github' })
const workflow = parse(pipeline.content)
expect(workflow.on.pull_request.types).toContain('closed')
expect(workflow.jobs.preview.steps.at(-1)).toEqual({
run: 'npx @capgo/cli@latest bundle upload com.example.app --channel pr-${{ github.event.pull_request.number }} --bundle "$CAPGO_PREVIEW_BUNDLE" --fail-on-incompatible',
env: { CAPGO_TOKEN: '${{ secrets.CAPGO_PREVIEW_TOKEN }}', CAPGO_PREVIEW_BUNDLE: '${{ steps.preview_version.outputs.bundle }}' },
})
expect(workflow.jobs['preview-cleanup'].steps.map((step: { uses?: string, run?: string }) => step.uses ?? step.run)).toEqual([
'actions/checkout@v6',
'actions/setup-node@v6',
'npm ci',
'npx @capgo/cli@latest channel delete pr-${{ github.event.pull_request.number }} com.example.app --delete-bundle --success-if-not-found',
])
expect(workflow.jobs['preview-cleanup'].steps[0].with).toEqual({ ref: '${{ github.event.pull_request.base.ref }}', 'persist-credentials': false })
expect(workflow.jobs.preview.steps[0].with).toEqual({ 'persist-credentials': false })
// Fork pull requests have no secrets, so their jobs skip.
expect(workflow.jobs.preview.if).toContain('github.event.pull_request.head.repo.full_name == github.repository')
expect(pipeline.cleansUpPreviews).toBe(true)
expect(pipeline.keys.map(key => [key.secret, key.role])).toEqual([
['CAPGO_TOKEN', 'app_uploader'],
['CAPGO_PREVIEW_TOKEN', 'app_preview'],
])
})
it('stops GitLab preview environments to delete the channel', () => {
const gitlab = parse(buildCiPipeline({ ...base, product: 'ota', strategy: 'pull_request', provider: 'gitlab' }).content)
expect(gitlab['capgo-preview'].environment).toEqual({
name: 'capgo/pr-$CI_MERGE_REQUEST_IID',
on_stop: 'capgo-preview-cleanup',
auto_stop_in: '2 weeks',
})
expect(gitlab['capgo-preview-cleanup'].environment.action).toBe('stop')
// The CLI loads the Capacitor config, so cleanup keeps the checkout and installs dependencies.
expect(gitlab['capgo-preview-cleanup'].variables).toBeUndefined()
expect(gitlab['capgo-preview-cleanup'].script).toEqual([
'npm ci',
'CAPGO_TOKEN="$CAPGO_PREVIEW_TOKEN" npx @capgo/cli@latest channel delete pr-$CI_MERGE_REQUEST_IID com.example.app --delete-bundle --success-if-not-found',
])
})
it('uses each provider\'s pull request number and keeps cleanup manual where CI has no close event', () => {
const expected = {
azure: 'pr-${SYSTEM_PULLREQUEST_PULLREQUESTNUMBER:-$SYSTEM_PULLREQUEST_PULLREQUESTID}',
bitbucket: 'pr-$BITBUCKET_PR_ID',
circleci: 'pr-${CIRCLE_PULL_REQUEST##*/}',
other: 'pr-$PR_NUMBER',
} as const
for (const [provider, channel] of Object.entries(expected)) {
const pipeline = buildCiPipeline({ ...base, product: 'ota', strategy: 'pull_request', provider: provider as keyof typeof expected })
expect(pipeline.content).toContain(`--channel ${channel} `)
expect(pipeline.cleansUpPreviews).toBe(false)
expect(pipeline.content).not.toContain('channel delete')
}
})
it('isolates Azure secrets in independently checked release and preview stages', () => {
const azure = parse(buildCiPipeline({ ...base, product: 'ota', strategy: 'pull_request', provider: 'azure' }).content)
expect(azure.variables).toBeUndefined()
expect(azure.stages.map((stage: { variables: unknown }) => stage.variables)).toEqual([[{ group: 'capgo-release' }], [{ group: 'capgo-preview' }]])
expect(azure.stages.every((stage: { dependsOn: unknown[] }) => stage.dependsOn.length === 0)).toBe(true)
expect(azure.stages.flatMap((stage: { jobs: { job: string }[] }) => stage.jobs.map(job => job.job))).toEqual(['production', 'preview'])
expect(azure.stages[0].jobs[0].steps.at(-1).env).toEqual({ CAPGO_TOKEN: '$(CAPGO_TOKEN)' })
expect(azure.stages[1].jobs[0].steps.at(-1).env).toEqual({ CAPGO_TOKEN: '$(CAPGO_PREVIEW_TOKEN)' })
expect(parse(buildCiPipeline({ ...base, product: 'ota', strategy: 'main', provider: 'azure' }).content).pr).toBe('none')
})
it('builds each selected native platform with stored credentials', () => {
const pipeline = buildCiPipeline({ ...base, product: 'builder', strategy: 'main', provider: 'github', platforms: ['android'] })
const workflow = parse(pipeline.content)
expect(Object.keys(workflow.jobs)).toEqual(['android-release'])
expect(workflow.jobs['android-release'].steps.map((step: { run?: string }) => step.run).filter(Boolean)).toEqual([
'npm ci',
'npm run build',
'npx cap sync android',
'npx @capgo/cli@latest build request com.example.app --platform android --use-stored-credentials --android-track internal --android-release-status draft',
])
expect(pipeline.keys).toEqual([{ secret: 'CAPGO_TOKEN', role: 'app_admin', purpose: 'release' }])
})
it('submits main for store review when preprod ships to testers', () => {
const jobs = buildCiJobs({ ...base, product: 'builder', strategy: 'main_preprod', provider: 'bitbucket' })
expect(jobs.map(job => job.id)).toEqual(['ios-release', 'android-release', 'ios-preprod', 'android-preprod'])
expect(jobs.filter(job => job.command.includes('--submit-to-store-review')).map(job => job.id)).toEqual(['ios-release', 'android-release'])
const bitbucket = parse(buildCiPipeline({ ...base, product: 'builder', strategy: 'main_preprod', provider: 'bitbucket' }).content)
expect(Object.keys(bitbucket.pipelines.branches)).toEqual(['main', 'preprod'])
expect(bitbucket.pipelines.branches.main[0].parallel).toHaveLength(2)
})
it('keeps tester and preview builds away from store review whatever the stored settings say', () => {
const jobs = buildCiJobs({ ...base, product: 'builder', strategy: 'main_preprod', provider: 'github' })
expect(jobs.filter(job => job.env?.CAPGO_STORE_SUBMIT_REVIEW === 'false').map(job => job.id)).toEqual(['ios-preprod', 'android-preprod'])
expect(jobs.find(job => job.id === 'android-preprod')?.command).toContain('--android-track internal --android-release-status draft')
expect(jobs.find(job => job.id === 'android-release')?.env).toBeUndefined()
const previews = buildCiJobs({ ...base, product: 'builder', strategy: 'pull_request', provider: 'github' })
expect(previews.filter(job => job.env).map(job => job.id)).toEqual(['ios-release', 'android-release', 'ios-preview', 'android-preview'])
const github = parse(buildCiPipeline({ ...base, product: 'builder', strategy: 'main_preprod', provider: 'github' }).content)
expect(github.jobs['ios-preprod'].steps.at(-1).env).toEqual({ CAPGO_TOKEN: '${{ secrets.CAPGO_TOKEN }}', CAPGO_STORE_SUBMIT_REVIEW: 'false' })
expect(github.jobs['ios-release'].steps.at(-1).env).toEqual({ CAPGO_TOKEN: '${{ secrets.CAPGO_TOKEN }}' })
const azure = parse(buildCiPipeline({ ...base, product: 'builder', strategy: 'pull_request', provider: 'azure', platforms: ['ios'] }).content)
expect(azure.stages[1].jobs[0].steps.at(-1).env).toEqual({ CAPGO_TOKEN: '$(CAPGO_PREVIEW_TOKEN)', CAPGO_STORE_SUBMIT_REVIEW: 'false' })
const gitlab = parse(buildCiPipeline({ ...base, product: 'builder', strategy: 'pull_request', provider: 'gitlab', platforms: ['ios'] }).content)
expect(gitlab['capgo-ios-preview'].script.at(-1)).toBe('CAPGO_STORE_SUBMIT_REVIEW=false CAPGO_TOKEN="$CAPGO_PREVIEW_TOKEN" npx @capgo/cli@latest build request com.example.app --platform ios --use-stored-credentials')
})
it('builds installable Android previews and TestFlight iOS previews for pull requests', () => {
const jobs = buildCiJobs({ ...base, product: 'builder', strategy: 'pull_request', provider: 'circleci' })
expect(jobs.find(job => job.id === 'android-preview')?.command).toBe('npx @capgo/cli@latest build request com.example.app --platform android --use-stored-credentials --build-mode debug --no-playstore-upload --output-upload')
expect(jobs.find(job => job.id === 'ios-preview')?.command).toBe('npx @capgo/cli@latest build request com.example.app --platform ios --use-stored-credentials')
// Native previews use a separate key restricted to approved, trusted code.
expect(jobs.filter(job => job.trigger.kind === 'pull_request').map(job => job.secret)).toEqual(['CAPGO_PREVIEW_TOKEN', 'CAPGO_PREVIEW_TOKEN'])
expect(jobs.filter(job => job.trigger.kind === 'push').map(job => job.secret)).toEqual(['CAPGO_TOKEN', 'CAPGO_TOKEN'])
expect(ciKeysFor('builder', 'pull_request')).toEqual([
{ secret: 'CAPGO_TOKEN', role: 'app_admin', purpose: 'release' },
{ secret: 'CAPGO_PREVIEW_TOKEN', role: 'app_admin', purpose: 'preview' },
])
const gitlab = parse(buildCiPipeline({ ...base, product: 'builder', strategy: 'pull_request', provider: 'gitlab', platforms: ['android'] }).content)
expect(gitlab['capgo-android-preview'].script.at(-1)).toBe('CAPGO_STORE_SUBMIT_REVIEW=false CAPGO_TOKEN="$CAPGO_PREVIEW_TOKEN" npx @capgo/cli@latest build request com.example.app --platform android --use-stored-credentials --build-mode debug --no-playstore-upload --output-upload')
const circleci = parse(buildCiPipeline({ ...base, product: 'builder', strategy: 'pull_request', provider: 'circleci' }).content)
expect(circleci.workflows.capgo.jobs).toContainEqual({ 'android-preview': { context: BUILDER_PREVIEW_SCOPE, requires: ['approve-builder-preview'], filters: { branches: { ignore: ['main'] } } } })
})
it('requires protected or approved credential access for native pull request builds', () => {
const options = { ...base, product: 'builder', strategy: 'pull_request' } as const
const github = parse(buildCiPipeline({ ...options, provider: 'github' }).content)
expect(github.jobs['ios-preview'].environment).toBe(BUILDER_PREVIEW_SCOPE)
expect(githubSecretCommand('CAPGO_PREVIEW_TOKEN', 'preview', 'builder')).toBe('gh secret set CAPGO_PREVIEW_TOKEN --env capgo-builder-preview')
expect(githubSecretCommand('CAPGO_PREVIEW_TOKEN', 'preview', 'ota')).toBe('gh secret set CAPGO_PREVIEW_TOKEN')
const gitlab = parse(buildCiPipeline({ ...options, provider: 'gitlab' }).content)
for (const platform of ['ios', 'android']) {
const preview = gitlab[`capgo-${platform}-preview`]
expect(preview.environment.name).toBe(BUILDER_PREVIEW_SCOPE)
expect(preview.rules[0]).toEqual({
if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_SOURCE_BRANCH_PROTECTED == "true" && $CI_MERGE_REQUEST_TARGET_BRANCH_PROTECTED == "true" && $CI_MERGE_REQUEST_SOURCE_PROJECT_ID == $CI_PROJECT_ID',
when: 'manual',
})
expect(gitlab[`capgo-${platform}-release`].environment.name).toBe('capgo-release')
}
const azure = parse(buildCiPipeline({ ...options, provider: 'azure' }).content)
expect(azure.stages[1].variables).toEqual([{ group: BUILDER_PREVIEW_SCOPE }])
const bitbucket = parse(buildCiPipeline({ ...options, provider: 'bitbucket' }).content)
const previewStages = bitbucket.pipelines['pull-requests']['**']
expect(previewStages[0].step.environment).toBeUndefined()
expect(previewStages[0].step.script.join(' ')).not.toContain('CAPGO_')
expect(previewStages[1].stage.trigger).toBe('manual')
expect(previewStages[1].stage.environment).toBe(BUILDER_PREVIEW_SCOPE)
expect(previewStages[1].stage.steps).toHaveLength(2)
const circleci = parse(buildCiPipeline({ ...options, provider: 'circleci' }).content)
expect(circleci.workflows.capgo.jobs).toContainEqual({ 'approve-builder-preview': { type: 'approval', filters: { branches: { ignore: ['main'] } } } })
})
it('restores the encryption key before every live update upload of an encrypted app', () => {
const restore = 'echo "$CAPGO_PRIVATE_KEY_BASE64" | base64 -d > .capgo_key_v2'
expect(CI_RESTORE_KEY_COMMAND).toBe(restore)
const github = buildCiPipeline({ ...base, product: 'ota', strategy: 'pull_request', provider: 'github', encrypted: true })
expect(github.extraSecrets).toEqual([CI_ENCRYPTION_SECRET])
const workflow = parse(github.content)
for (const job of ['production', 'preview']) {
const steps = workflow.jobs[job].steps
const index = steps.findIndex((step: { run?: string }) => step.run === restore)
expect(steps[index].env).toEqual({ CAPGO_PRIVATE_KEY_BASE64: '${{ secrets.CAPGO_PRIVATE_KEY_BASE64 }}' })
expect(steps.at(-1).run).toContain('bundle upload')
expect(index).toBeLessThan(steps.length - 1)
}
expect(workflow.jobs.production_build.steps.some((step: { run?: string }) => step.run === restore)).toBe(false)
// Deleting a channel uploads nothing, so it never needs the key.
expect(workflow.jobs['preview-cleanup'].steps.some((step: { run?: string }) => step.run === restore)).toBe(false)
const azure = parse(buildCiPipeline({ ...base, product: 'ota', strategy: 'main', provider: 'azure', encrypted: true }).content)
expect(azure.stages[0].jobs[0].steps.find((step: { script?: string }) => step.script === restore).env).toEqual({ CAPGO_PRIVATE_KEY_BASE64: '$(CAPGO_PRIVATE_KEY_BASE64)' })
for (const provider of ['gitlab', 'bitbucket', 'circleci', 'other'] as const)
expect(buildCiPipeline({ ...base, product: 'ota', strategy: 'main', provider, encrypted: true }).content, provider).toContain(restore)
expect(buildCiPipeline({ ...base, product: 'ota', strategy: 'main', provider: 'github' }).extraSecrets).toEqual([])
expect(buildCiPipeline({ ...base, product: 'builder', strategy: 'main', provider: 'github', encrypted: true }).content).not.toContain('.capgo_key_v2')
expect(encryptionSecretCommand('github')).toBe(`base64 < .capgo_key_v2 | tr -d '\\n' | gh secret set CAPGO_PRIVATE_KEY_BASE64 --env capgo-release`)
})
it('keeps GitHub encryption and preview API keys behind the protected preview environment', () => {
const pipeline = buildCiPipeline({ ...base, product: 'ota', strategy: 'pull_request', provider: 'github', encrypted: true })
const github = parse(pipeline.content)
expect(github.jobs.preview.environment).toBe(ENCRYPTED_PREVIEW_SCOPE)
// Cleanup executes repository config too, so it needs the same trusted-code approval.
expect(github.jobs['preview-cleanup'].environment).toBe(ENCRYPTED_PREVIEW_SCOPE)
expect(github.jobs['preview-cleanup'].steps.some((step: { run?: string }) => step.run === CI_RESTORE_KEY_COMMAND)).toBe(false)
expect(github.jobs.production.environment).toBe('capgo-release')
expect(githubSecretCommand('CAPGO_PREVIEW_TOKEN', 'preview', 'ota', true)).toBe(`gh secret set CAPGO_PREVIEW_TOKEN --env ${ENCRYPTED_PREVIEW_SCOPE}`)
expect(githubSecretCommand(CI_ENCRYPTION_SECRET, 'preview')).toBe(`gh secret set CAPGO_PRIVATE_KEY_BASE64 --env ${ENCRYPTED_PREVIEW_SCOPE}`)
expect(encryptionSecretCommand('github', 'preview')).toBe(`base64 < .capgo_key_v2 | tr -d '\\n' | gh secret set CAPGO_PRIVATE_KEY_BASE64 --env ${ENCRYPTED_PREVIEW_SCOPE}`)
})
it('requires protected GitLab branches and manual approval for encrypted preview upload and cleanup', () => {
const gitlab = parse(buildCiPipeline({ ...base, product: 'ota', strategy: 'pull_request', provider: 'gitlab', encrypted: true }).content)
for (const id of ['capgo-preview', 'capgo-preview-cleanup']) {
expect(gitlab[id].rules).toEqual([{
if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_SOURCE_BRANCH_PROTECTED == "true" && $CI_MERGE_REQUEST_TARGET_BRANCH_PROTECTED == "true" && $CI_MERGE_REQUEST_SOURCE_PROJECT_ID == $CI_PROJECT_ID',
when: 'manual',
}])
expect(gitlab[id].environment.name).toBe('capgo/pr-$CI_MERGE_REQUEST_IID')
expect(gitlab[id].resource_group).toBe('capgo-com.example.app')
}
expect(gitlab['capgo-preview'].environment.on_stop).toBe('capgo-preview-cleanup')
expect(gitlab['capgo-preview-cleanup'].environment.action).toBe('stop')
expect(gitlab['capgo-preview-cleanup'].script).not.toContain(CI_RESTORE_KEY_COMMAND)
})
it('requires restricted approval scopes before other providers can use the bundle signing key', () => {
const options = { ...base, product: 'ota', strategy: 'pull_request', encrypted: true } as const
const azure = parse(buildCiPipeline({ ...options, provider: 'azure' }).content)
expect(azure.stages[1].variables).toEqual([{ group: ENCRYPTED_PREVIEW_SCOPE }])
const bitbucket = parse(buildCiPipeline({ ...options, provider: 'bitbucket' }).content)
const stages = bitbucket.pipelines['pull-requests']['**']
expect(stages[0].step.environment).toBeUndefined()
expect(stages[0].step.script.join(' ')).not.toContain('CAPGO_')
expect(stages[1].stage).toMatchObject({ name: 'Build approved encrypted previews', trigger: 'manual', environment: ENCRYPTED_PREVIEW_SCOPE })
expect(stages[1].stage.steps[0].step.script).toContain(CI_RESTORE_KEY_COMMAND)
const circleci = parse(buildCiPipeline({ ...options, provider: 'circleci' }).content)
expect(circleci.workflows.capgo.jobs).toContainEqual({ 'approve-encrypted-preview': { type: 'approval', filters: { branches: { ignore: ['main'] } } } })
expect(circleci.workflows.capgo.jobs).toContainEqual({ preview: { context: ENCRYPTED_PREVIEW_SCOPE, requires: ['approve-encrypted-preview'], filters: { branches: { ignore: ['main'] } } } })
expect(buildCiPipeline({ ...options, provider: 'other' }).content).toContain('Require trusted-maintainer approval of this exact commit before exposing the bundle signing key.')
})
it('keeps numeric-looking branch names as strings in every provider', () => {
const options = { ...base, product: 'ota' as const, strategy: 'main_preprod' as const, productionBranch: '1.0', preprodBranch: 'null' }
expect(parse(buildCiPipeline({ ...options, provider: 'github' }).content).on.push.branches).toEqual(['1.0', 'null'])
expect(parse(buildCiPipeline({ ...options, provider: 'azure' }).content).trigger.branches.include).toEqual(['1.0', 'null'])
expect(Object.keys(parse(buildCiPipeline({ ...options, provider: 'bitbucket' }).content).pipelines.branches)).toEqual(['1.0', 'null'])
const circleci = parse(buildCiPipeline({ ...options, provider: 'circleci' }).content)
expect(circleci.workflows.capgo.jobs[0].production.filters.branches.only).toBe('1.0')
expect(parse(buildCiPipeline({ ...options, provider: 'gitlab' }).content)['capgo-production'].rules).toEqual([{ if: '$CI_COMMIT_BRANCH == "1.0"' }])
})
it('runs pull request previews and their cleanup one at a time per pull request', () => {
const github = parse(buildCiPipeline({ ...base, product: 'ota', strategy: 'pull_request', provider: 'github' }).content)
const concurrency = { group: 'capgo-preview-${{ github.event.pull_request.number }}', 'cancel-in-progress': true }
expect(github.jobs.preview.concurrency).toEqual(concurrency)
expect(github.jobs['preview-cleanup'].concurrency).toEqual(concurrency)
expect(github.concurrency).toBeUndefined()
expect(github.jobs.preview.concurrency.group).not.toBe(github.jobs.production.concurrency.group)
expect(github.jobs['preview-cleanup'].concurrency.group).not.toBe(github.jobs.production.concurrency.group)
const gitlab = parse(buildCiPipeline({ ...base, product: 'ota', strategy: 'pull_request', provider: 'gitlab' }).content)
expect(gitlab['capgo-preview'].resource_group).toBe('capgo-com.example.app')
expect(gitlab['capgo-preview-cleanup'].resource_group).toBe('capgo-com.example.app')
// Builder previews have no cleanup to race with.
expect(parse(buildCiPipeline({ ...base, product: 'builder', strategy: 'pull_request', provider: 'github' }).content).jobs['ios-preview'].concurrency).toBeUndefined()
})
it('uses the same GitLab app lock for production, tester, preview, and cleanup uploads', () => {
for (const strategy of CI_STRATEGIES) {
const pipeline = buildCiPipeline({ ...base, product: 'ota', strategy, provider: 'gitlab' })
const gitlab = parse(pipeline.content)
for (const job of pipeline.jobs)
expect(gitlab[`capgo-${job.id}`].resource_group).toBe('capgo-com.example.app')
}
const otherApp = parse(buildCiPipeline({ ...base, appId: 'com.example.other', product: 'ota', strategy: 'main', provider: 'gitlab' }).content)
expect(otherApp['capgo-production'].resource_group).toBe('capgo-com.example.other')
const builder = parse(buildCiPipeline({ ...base, product: 'builder', strategy: 'main_preprod', provider: 'gitlab' }).content)
expect(builder['capgo-ios-release'].resource_group).toBeUndefined()
})
it('serializes GitHub stable version selection across channels and checks the head only after acquiring the lock', () => {
for (const strategy of CI_STRATEGIES) {
const pipeline = buildCiPipeline({ ...base, product: 'ota', strategy, provider: 'github', encrypted: true })
const github = parse(pipeline.content)
expect(github.concurrency).toBeUndefined()
for (const job of pipeline.jobs) {
const rendered = github.jobs[job.id]
if (job.trigger.kind === 'push') {
const buildId = `${job.id}_build`
const build = github.jobs[buildId]
expect(build['runs-on']).toBe('ubuntu-latest')
expect(build.environment).toBeUndefined()
expect(build.concurrency).toBeUndefined()
expect(build.outputs.artifact_id).toBe('${{ steps.archive.outputs.artifact-id }}')
const archive = build.steps.at(-1)
expect(archive.uses).toBe('actions/upload-artifact@v7')
expect(archive.with.overwrite).toBe(true)
expect(rendered.concurrency).toEqual({ group: 'capgo-ota-com.example.app', 'cancel-in-progress': false, queue: 'max' })
expect(rendered.needs).toBe(buildId)
expect(rendered['runs-on']).toBe('ubuntu-latest')
const check = rendered.steps[0]
expect(check.id).toBe('release_head')
expect(check.env).toEqual({ GH_TOKEN: '${{ github.token }}' })
expect(check.uses).toBeUndefined()
expect(check.run).not.toMatch(/npm|npx|tar|checkout|artifact/)
expect(rendered.steps[1]).toEqual({
uses: 'actions/download-artifact@v8',
if: "steps.release_head.outputs.current == 'true'",
with: { 'artifact-ids': `\${{ needs.${buildId}.outputs.artifact_id }}`, path: '${{ runner.temp }}/capgo-release' },
})
for (const step of rendered.steps.slice(1))
expect(step.if).toBe("steps.release_head.outputs.current == 'true'")
expect(rendered.steps.some((step: { uses?: string }) => step.uses === 'actions/checkout@v6')).toBe(false)
}
else {
expect(rendered.steps.some((step: { id?: string }) => step.id === 'release_head')).toBe(false)
expect(rendered.concurrency.group).toBe('capgo-preview-${{ github.event.pull_request.number }}')
expect(rendered.concurrency['cancel-in-progress']).toBe(true)
expect(rendered.environment).toBe(ENCRYPTED_PREVIEW_SCOPE)
}
}
}
const otherApp = parse(buildCiPipeline({ ...base, appId: 'com.example.other', product: 'ota', strategy: 'main', provider: 'github' }).content)
expect(otherApp.jobs.production.concurrency.group).toBe('capgo-ota-com.example.other')
const builder = parse(buildCiPipeline({ ...base, product: 'builder', strategy: 'main_preprod', provider: 'github' }).content)
expect(builder.jobs['ios-release'].concurrency).toBeUndefined()
})
it('authenticates the branch check without Git credentials, skips superseded commits, and fails closed on API errors', async () => {
const github = parse(buildCiPipeline({ ...base, product: 'ota', strategy: 'main', provider: 'github' }).content)
const guard = github.jobs.production.steps[0]
expect(guard.shell).toBe('bash')
const directory = mkdtempSync(join(tmpdir(), 'capgo-release-order-'))
const newer = 'b'.repeat(40)
const token = 'cicd-fixture-token'
const requests: { path: string | undefined, authenticated: boolean }[] = []
const server = createServer((request, response) => {
const authenticated = request.headers.authorization === `token ${token}`
requests.push({ path: request.url, authenticated })
response.setHeader('Content-Type', 'application/json')
if (!authenticated) {
response.writeHead(401).end(JSON.stringify({ message: 'Bad credentials' }))
}
else if (request.url === '/repos/example/app/git/ref/heads/release/stable') {
response.end(JSON.stringify({ object: { sha: newer } }))
}
else {
response.writeHead(404).end(JSON.stringify({ message: 'Not Found' }))
}
})
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (!address || typeof address === 'string')
throw new Error('Expected a local API port')
const guardEnv = {
...env,
PATH: `${directory}${delimiter}${env.PATH ?? ''}`,
GH_CONFIG_DIR: directory,
GH_TOKEN: token,
GITHUB_API_URL: `http://127.0.0.1:${address.port}`,
GITHUB_REPOSITORY: 'example/app',
GITHUB_REF: 'refs/heads/release/stable',
GITHUB_RUN_ATTEMPT: '1',
}
try {
// Keep the shell guard and authenticated HTTP request real without requiring gh locally.
writeFileSync(join(directory, 'gh'), `#!${execPath}
const [command, endpoint, flag, query] = process.argv.slice(2)
if (command !== 'api' || flag !== '--jq' || query !== '.object.sha') process.exit(2)
void (async () => {
const response = await fetch(endpoint, { headers: { authorization: 'token ' + (process.env.GH_TOKEN || '') } })
if (!response.ok) process.exit(1)
const data = await response.json()
process.stdout.write(data.object.sha + '\\n')
})().catch(() => process.exit(1))
`, { mode: 0o755 })
const older = 'a'.repeat(40)
for (const [sha, expected] of [[older, 'false'], [newer, 'true']]) {
const output = join(directory, `${sha}.output`)
await runFile('bash', ['-e', '-o', 'pipefail', '-c', guard.run], {
cwd: directory,
env: { ...guardEnv, GITHUB_SHA: sha, GITHUB_OUTPUT: output },
})
expect(readFileSync(output, 'utf8').trim()).toBe(`current=${expected}`)
}
expect(requests).toEqual(Array.from({ length: 2 }, () => ({ path: '/repos/example/app/git/ref/heads/release/stable', authenticated: true })))
for (const overrides of [{ GITHUB_REF: 'refs/heads/missing' }, { GH_TOKEN: 'invalid-fixture-token' }, { GH_TOKEN: '' }]) {
const failedOutput = join(directory, 'failed.output')
await expect(runFile('bash', ['-e', '-o', 'pipefail', '-c', guard.run], {
cwd: directory,
env: { ...guardEnv, ...overrides, GITHUB_SHA: newer, GITHUB_OUTPUT: failedOutput },
})).rejects.toThrow()
expect(existsSync(failedOutput)).toBe(false)
}
}
finally {
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
rmSync(directory, { recursive: true, force: true })
}
})
it('uses collision-free next-patch preview versions without release credentials or an app-wide approval lock', async () => {
const workflow = parse(buildCiPipeline({ ...base, product: 'ota', strategy: 'pull_request', provider: 'github', encrypted: true }).content)
const preview = workflow.jobs.preview
const resolve = preview.steps.find((step: { id?: string }) => step.id === 'preview_version')
expect(resolve.env).toEqual({ PR_NUMBER: '${{ github.event.pull_request.number }}' })
expect(resolve.run).toContain('bundle zip com.example.app --for-upload --no-key --json --name "$RUNNER_TEMP/capgo-preview.zip"')
expect(preview.steps.indexOf(resolve)).toBeLessThan(preview.steps.findIndex((step: { run?: string }) => step.run === CI_RESTORE_KEY_COMMAND))
const directory = mkdtempSync(join(tmpdir(), 'capgo-preview-version-'))
const output = join(directory, 'output')
try {
// The published offline packer supplies the configured version; execute the generated resolver itself.
writeFileSync(join(directory, 'npx'), `#!${execPath}
const { writeFileSync } = require('node:fs')
const args = process.argv.slice(2)
if (args[0] !== '@capgo/cli@latest' || args[1] !== 'bundle' || args[2] !== 'zip' || !args.includes('--no-key') || process.env.CAPGO_TOKEN || process.env.CAPGO_PRIVATE_KEY_BASE64) process.exit(1)
writeFileSync(args[args.indexOf('--name') + 1], 'offline zip fixture')
process.stdout.write(JSON.stringify({ bundle: process.env.FIXTURE_BUNDLE }))
`, { mode: 0o755 })
const run = (bundle: string, overrides: Record<string, string> = {}) => runFile('bash', ['-e', '-o', 'pipefail', '-c', resolve.run], {
cwd: directory,
env: { ...env, CAPGO_TOKEN: '', CAPGO_PRIVATE_KEY_BASE64: '', PATH: `${directory}${delimiter}${env.PATH ?? ''}`, RUNNER_TEMP: directory, GITHUB_OUTPUT: output, GITHUB_REPOSITORY_ID: '101', PR_NUMBER: '7', GITHUB_RUN_ID: '202', GITHUB_RUN_ATTEMPT: '1', FIXTURE_BUNDLE: bundle, ...overrides },
})
for (const bundle of ['0.0.0', '1.2.3', '1.2.3-beta.7+build', '2.9.999999']) {
rmSync(output, { force: true })
await run(bundle)
const version = readFileSync(output, 'utf8').trim().slice('bundle='.length)
expect(version).toMatch(/-pr\.101\.7\.202\.1$/)
// Default native downgrade protection accepts the next-patch prerelease.
expect(lessThan(parseSemver(bundle), parseSemver(version))).toBe(true)
// A first stable upload based on the latest preview chooses a distinct stable release.
expect(autoBumpVersionBy(version, 'patch')).not.toContain('-')
expect(autoBumpVersionBy(version, 'patch')).not.toBe(version)
expect(existsSync(join(directory, 'capgo-preview.zip'))).toBe(false)
expect(existsSync(join(directory, 'capgo-preview.json'))).toBe(false)
}
const versions = new Set<string>()
const identityOverrides: Array<Record<string, string>> = [{}, { GITHUB_RUN_ID: '203' }, { GITHUB_RUN_ATTEMPT: '2' }, { GITHUB_REPOSITORY_ID: '102' }, { PR_NUMBER: '8' }]
for (const overrides of identityOverrides) {
rmSync(output, { force: true })
await run('1.2.3', overrides)
versions.add(readFileSync(output, 'utf8'))
}
expect(versions.size).toBe(5)
for (const [bundle, overrides] of [['invalid', {}], ['01.2.3', {}], ['1.2.3', { GITHUB_RUN_ID: 'unsafe-value' }]] as const) {
rmSync(output, { force: true })
await expect(run(bundle, overrides)).rejects.toThrow()
expect(existsSync(output)).toBe(false)
}
}
finally {
rmSync(directory, { recursive: true, force: true })
}
})
it('feeds real offline packer JSON into the generated preview version resolver', async () => {
const workflow = parse(buildCiPipeline({ ...base, product: 'ota', strategy: 'pull_request', provider: 'github' }).content)
const resolve = workflow.jobs.preview.steps.find((step: { id?: string }) => step.id === 'preview_version')
const directory = mkdtempSync(join(tmpdir(), 'capgo-preview-packer-'))
const project = join(directory, 'project')
const bin = join(directory, 'bin')
const temporary = join(directory, 'runner-temp')
const output = join(temporary, 'output')
try {
for (const path of [bin, temporary, join(project, 'dist'), join(project, 'node_modules/@capgo/capacitor-updater')])
mkdirSync(path, { recursive: true })
writeFileSync(join(project, 'package.json'), JSON.stringify({ name: 'preview-packer-fixture', version: '1.2.3', dependencies: { '@capgo/capacitor-updater': '7.30.0' } }))
writeFileSync(join(project, 'node_modules/@capgo/capacitor-updater/package.json'), JSON.stringify({ name: '@capgo/capacitor-updater', version: '7.30.0' }))
writeFileSync(join(project, 'dist/index.html'), '<html><body><script src="app.js"></script></body></html>')
writeFileSync(join(project, 'dist/app.js'), 'CapacitorUpdater.notifyAppReady();\n')
// Resolve npx to the workspace CLI without downloading a package or fabricating its JSON.
writeFileSync(join(bin, 'npx'), '#!/bin/sh\nshift\nexec bun "$CAPGO_CLI_ENTRY" "$@"\n', { mode: 0o755 })
for (const [version, expected] of [['5.4.3', '5.4.4'], [null, '1.2.4']] as const) {
writeFileSync(join(project, 'capacitor.config.json'), JSON.stringify({ appId: 'com.example.app', appName: 'Preview', webDir: 'dist', plugins: { CapacitorUpdater: version ? { version } : {} } }))
rmSync(output, { force: true })
await runFile('bash', ['-e', '-o', 'pipefail', '-c', resolve.run], {
cwd: project,
env: {
...env,
PATH: `${bin}${delimiter}${env.PATH ?? ''}`,
CAPGO_CLI_ENTRY: join(dirname(fileURLToPath(import.meta.url)), '..', 'cli/src/index.ts'),
CAPGO_DISABLE_TELEMETRY: 'true',
CAPGO_TOKEN: '',
CAPGO_PRIVATE_KEY_BASE64: '',
RUNNER_TEMP: temporary,
GITHUB_OUTPUT: output,
GITHUB_REPOSITORY_ID: '101',
PR_NUMBER: '7',
GITHUB_RUN_ID: '202',
GITHUB_RUN_ATTEMPT: '1',
},
})
expect(readFileSync(output, 'utf8').trim()).toBe(`bundle=${expected}-pr.101.7.202.1`)
expect(existsSync(join(temporary, 'capgo-preview.zip'))).toBe(false)
expect(existsSync(join(temporary, 'capgo-preview.json'))).toBe(false)
}
}
finally {
rmSync(directory, { recursive: true, force: true })
}
}, 20000)
it('preserves generated config dependencies without rerunning install scripts or transferring private keys', async () => {
const workflow = parse(buildCiPipeline({ ...base, product: 'ota', strategy: 'main', provider: 'github' }).content)
const archive = workflow.jobs.production_build.steps.find((step: { run?: string }) => step.run?.startsWith('tar ')).run
const directory = mkdtempSync(join(tmpdir(), 'capgo-release-artifact-'))
const project = join(directory, 'project')
const deployment = join(directory, 'deployment')
const temporary = join(directory, 'temporary')
try {
const dependency = join(project, 'node_modules/config-fixture')
for (const path of ['.git', 'node_modules/config-fixture', 'web/custom', '.config'])
mkdirSync(join(project, path), { recursive: true })
mkdirSync(deployment)
mkdirSync(join(temporary, 'capgo-release'), { recursive: true })
writeFileSync(join(project, 'web/custom/index.html'), '<p>Built app</p>')
writeFileSync(join(project, 'web/custom/script.sh'), '#!/bin/sh\nexit 0\n', { mode: 0o755 })
writeFileSync(join(project, 'capacitor.config.js'), 'module.exports = require("config-fixture")\n')
writeFileSync(join(project, '.config/channel.json'), '{"channel":"stable"}')
writeFileSync(join(project, '.git/config'), 'fixture git configuration')
writeFileSync(join(dependency, 'package.json'), JSON.stringify({ name: 'config-fixture', version: '1.0.0', main: 'generated.cjs', scripts: { postinstall: 'node postinstall.cjs' } }))
writeFileSync(join(dependency, 'postinstall.cjs'), 'require("node:fs").writeFileSync("generated.cjs", \'module.exports = { webDir: "web/custom" }\\n\')\n')
await runFile(execPath, ['postinstall.cjs'], { cwd: dependency })
writeFileSync(join(project, '.capgo_key_v2'), 'fixture private key')
const name = 'cicd-artifact-fixture'
const version = '1.0.0'
writeFileSync(join(project, 'package.json'), JSON.stringify({
name,
version,
scripts: { postinstall: 'node -e "require(\'node:fs\').writeFileSync(\'web/custom/index.html\', \'Changed by install\')"' },
}))
writeFileSync(join(project, 'package-lock.json'), JSON.stringify({ name, version, lockfileVersion: 3, packages: { '': { name, version, hasInstallScript: true } } }))
const commandEnv = { ...env, RUNNER_TEMP: temporary, GITHUB_RUN_ATTEMPT: '1', npm_config_audit: 'false', npm_config_fund: 'false', npm_config_update_notifier: 'false' }
await runFile('bash', ['-e', '-o', 'pipefail', '-c', archive], { cwd: project, env: commandEnv })
copyFileSync(join(temporary, 'capgo-release.tar.gz'), join(temporary, 'capgo-release/capgo-release.tar.gz'))
for (const step of workflow.jobs.production.steps.slice(1, -1)) {
if (step.run)
await runFile('bash', ['-e', '-o', 'pipefail', '-c', step.run], { cwd: deployment, env: commandEnv })
}
for (const path of ['.git', '.capgo_key_v2'])
expect(existsSync(join(deployment, path)), path).toBe(false)
expect(readFileSync(join(deployment, 'web/custom/index.html'), 'utf8')).toBe('<p>Built app</p>')
const config = await runFile(execPath, ['-e', 'process.stdout.write(require("./capacitor.config.js").webDir)'], { cwd: deployment })
expect(config.stdout).toBe('web/custom')
expect(readFileSync(join(deployment, '.config/channel.json'), 'utf8')).toBe('{"channel":"stable"}')
expect(statSync(join(deployment, 'web/custom/script.sh')).mode & 0o777).toBe(0o755)
}
finally {
rmSync(directory, { recursive: true, force: true })
}
})
it('rejects branch and channel names that would break the YAML', () => {
expect(normalizeCiBranch(' develop ', 'main')).toBe('develop')
expect(normalizeCiBranch('feature/a.b_c-1', 'main')).toBe('feature/a.b_c-1')
for (const value of ['', 'a b', 'a: b', '\'quoted\'', 'x\ny', '../up', 'a//b', '#hash', '*'])
expect(normalizeCiBranch(value, 'main')).toBe('main')
// The app's real default channel is kept, whatever its name; only a missing one falls back.
expect(normalizeCiChannel('beta-1')).toBe('beta-1')
expect(normalizeCiChannel('Live app / v2')).toBe('Live app / v2')
expect(normalizeCiChannel(null)).toBe('production')
expect(normalizeCiChannel('')).toBe('production')
})
it('quotes unusual default channel names instead of replacing them', () => {
const channel = 'Store: it\'s #1 / ${HOME}'
const shell = `'Store: it'\\''s #1 / \${HOME}'`
const expected = `npx @capgo/cli@latest bundle upload com.example.app --channel ${shell} --auto-bump patch --fail-on-incompatible`
const commands = {
github: (yaml: any) => yaml.jobs.production.steps.at(-1).run,
gitlab: (yaml: any) => yaml['capgo-production'].script.at(-1),
azure: (yaml: any) => yaml.stages[0].jobs[0].steps.at(-1).script,
bitbucket: (yaml: any) => yaml.pipelines.branches.main[0].step.script.at(-1),
circleci: (yaml: any) => yaml.jobs.production.steps.at(-1).run.command,
} as const
for (const [provider, read] of Object.entries(commands)) {
const pipeline = buildCiPipeline({ ...base, productionChannels: [channel], product: 'ota', strategy: 'main', provider: provider as keyof typeof commands })
expect(read(parse(pipeline.content)), provider).toBe(expected)
}
expect(buildCiPipeline({ ...base, productionChannels: [channel], product: 'ota', strategy: 'main', provider: 'other' }).content).toContain(expected)
expect(parse(buildCiPipeline({ ...base, productionChannels: [channel], product: 'ota', strategy: 'main', provider: 'github' }).content).jobs.production.name).toBe(`Upload live update to ${channel}`)
})
it('uploads to every default channel when each platform has its own', () => {
const pipeline = buildCiPipeline({ ...base, productionChannels: ['production-ios', 'production-android', 'production-ios'], product: 'ota', strategy: 'main', provider: 'github' })
const job = parse(pipeline.content).jobs.production
expect(job.steps.at(-1).run).toBe('npx @capgo/cli@latest bundle upload com.example.app --channel production-android,production-ios --auto-bump patch --fail-on-incompatible')
expect(job.name).toBe('Upload live update to production-android, production-ios')
expect(normalizeCiChannels([])).toEqual([])
expect(normalizeCiChannels(null)).toEqual([])
expect(normalizeCiChannels(undefined)).toEqual([])
expect(normalizeCiChannels(['b', '', 'a', 'b'])).toEqual(['a', 'b'])
// A name with spaces stays one quoted --channel value.
expect(buildCiPipeline({ ...base, productionChannels: ['Live iOS', 'Live Android'], product: 'ota', strategy: 'main', provider: 'other' }).content)
.toContain(`--channel 'Live Android,Live iOS' --auto-bump`)
})
it('flags channel names the CLI channel option cannot target', () => {
expect(isCiChannelSupported('production')).toBe(true)
expect(isCiChannelSupported('Live app / v2')).toBe(true)
for (const name of ['', 'a,b', ' production', 'production ', 'prod\nuction', 'prod\tuction', '${{ github.event.head_commit.message }}'])
expect(isCiChannelSupported(name), JSON.stringify(name)).toBe(false)
})
it('rejects GitHub expression syntax before rendering production or tester channel names', () => {
const channel = '${{ github.event.head_commit.message }}'
for (const provider of CI_PROVIDERS) {
for (const strategy of CI_STRATEGIES)
expect(() => buildCiPipeline({ ...base, productionChannels: [channel], product: 'ota', strategy, provider })).toThrow('Production channels must be supported')
expect(() => buildCiPipeline({ ...base, product: 'ota', strategy: 'main_preprod', preprodChannel: channel, provider })).toThrow('Tester channel must be supported')
}
})
it.each([
'$(releaseChannel)',
'release-$(releaseChannel)',
'$(Build.SourceBranchName)',
'$()release-$(releaseChannel)',
'$[[ inputs.release_channel ]]',
'$[[]]release-$[[ inputs.release_channel ]]',
'<< pipeline.git.branch >>',
'<<>>release-<< pipeline.git.branch >>',
])('rejects interpolated release target %s before rendering any pipeline', (channel) => {
expect(isCiChannelSupported(channel)).toBe(false)
for (const provider of CI_PROVIDERS) {
for (const strategy of CI_STRATEGIES)
expect(() => buildCiPipeline({ ...base, productionChannels: [channel], product: 'ota', strategy, provider })).toThrow('Production channels must be supported')
expect(() => buildCiPipeline({ ...base, product: 'ota', strategy: 'main_preprod', preprodChannel: channel, provider })).toThrow('Tester channel must be supported')
}
})
it('keeps literal punctuation that is not a CI expression', () => {
for (const channel of ['release$cash', 'Live (iOS)', 'release$()', 'release$(', 'release$[[', 'release<<', 'release<<>>', 'release$[variables.channel]']) {
expect(isCiChannelSupported(channel), channel).toBe(true)
expect(buildCiPipeline({ ...base, productionChannels: [channel], product: 'ota', strategy: 'main', provider: 'azure' }).jobs[0]?.command).toContain(`--channel '${channel}'`)
}
})
it('prints the credentials push command for each platform', () => {
expect(builderCredentialsPushCommand(appId, 'ios')).toBe('npx @capgo/cli@latest build credentials push com.example.app --platform ios')
})
})
describe('CI/CD guide selection storage', () => {
beforeEach(() => localStorage.clear())
it('keeps valid answers per user and app', () => {
saveCiGuideSelection('user-1', appId, { product: 'builder', strategy: 'pull_request', provider: 'gitlab', platforms: ['ios'], productionBranch: 'master', preprodBranch: 'preprod' })
expect(loadCiGuideSelection('user-1', appId)).toEqual({ product: 'builder', strategy: 'pull_request', provider: 'gitlab', platforms: ['ios'], productionBranch: 'master', preprodBranch: 'preprod' })
expect(loadCiGuideSelection('user-2', appId)).toEqual({})
expect(loadCiGuideSelection(null, appId)).toEqual({})
})
it('remembers a valid custom tester channel and drops unsupported stored names', () => {
saveCiGuideSelection('user-1', appId, { product: 'ota', strategy: 'main_preprod', provider: 'github', platforms: ['ios'], productionBranch: 'main', preprodBranch: 'preprod', preprodChannel: 'ci-testers' })
expect(loadCiGuideSelection('user-1', appId).preprodChannel).toBe('ci-testers')
localStorage.setItem(`capgo:cicd-guide:user-1:${appId}`, JSON.stringify({ preprodChannel: 'production,preprod' }))
expect(loadCiGuideSelection('user-1', appId).preprodChannel).toBeUndefined()
})
it('remembers an existing production target and drops malformed stored choices', () => {
saveCiGuideSelection('user-1', appId, { product: 'ota', strategy: 'main', provider: 'github', platforms: ['ios'], productionBranch: 'main', preprodBranch: 'preprod', productionChannel: 'private-live' })
expect(loadCiGuideSelection('user-1', appId).productionChannel).toBe('private-live')
for (const productionChannel of ['', 'private,live', ' private-live', 'private-live\n', null, 42, ['private-live']]) {
localStorage.setItem(`capgo:cicd-guide:user-1:${appId}`, JSON.stringify({ productionChannel }))
expect(loadCiGuideSelection('user-1', appId).productionChannel).toBeUndefined()
}
})
it('drops stored production and tester names containing provider expressions', () => {
for (const channel of ['$(releaseChannel)', '${{ github.ref_name }}', '$[[ inputs.channel ]]', '<< pipeline.git.branch >>']) {
localStorage.setItem(`capgo:cicd-guide:user-1:${appId}`, JSON.stringify({ productionChannel: channel, preprodChannel: channel }))
expect(loadCiGuideSelection('user-1', appId)).toEqual({})
}
})
it('drops unknown or malformed values', () => {
localStorage.setItem(`capgo:cicd-guide:user-1:${appId}`, JSON.stringify({ product: 'web', strategy: 'nightly', provider: 'jenkins', platforms: ['ios', 'windows', 'ios'], productionBranch: 'a b' }))
expect(loadCiGuideSelection('user-1', appId)).toEqual({ platforms: ['ios'], productionBranch: 'main' })
localStorage.setItem(`capgo:cicd-guide:user-1:${appId}`, '{not json')
expect(loadCiGuideSelection('user-1', appId)).toEqual({})
})
})