diff --git a/.github/workflows/mobile-android-release.yml b/.github/workflows/mobile-android-release.yml index 3e0045f24..0fcf6ff2a 100644 --- a/.github/workflows/mobile-android-release.yml +++ b/.github/workflows/mobile-android-release.yml @@ -9,22 +9,8 @@ on: - 'mobile-android-v*' workflow_dispatch: inputs: - bump_patch_version: - description: 'Bump the mobile marketing version patch number before release' - required: false - default: false - type: boolean release_version: - description: 'Optional exact mobile marketing version override, e.g. 0.0.15' - required: false - type: string - bump_android_version_code: - description: 'Increment the Android versionCode before release' - required: false - default: false - type: boolean - android_version_code: - description: 'Optional exact Android versionCode override' + description: 'Optional exact mobile marketing version assertion, e.g. 0.0.22' required: false type: string publish_github_release: @@ -69,9 +55,6 @@ jobs: id: release env: MOBILE_ANDROID_RELEASE_VERSION: ${{ github.event.inputs.release_version }} - MOBILE_ANDROID_BUMP_PATCH_VERSION: ${{ github.event.inputs.bump_patch_version }} - MOBILE_ANDROID_VERSION_CODE: ${{ github.event.inputs.android_version_code }} - MOBILE_ANDROID_BUMP_VERSION_CODE: ${{ github.event.inputs.bump_android_version_code }} MOBILE_ANDROID_PUBLISH_RELEASE: ${{ github.event.inputs.publish_github_release }} run: node scripts/prepare-android-release.mjs diff --git a/mobile/app.json b/mobile/app.json index e957cefba..27d3b7d88 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -75,7 +75,7 @@ "allowBackup": false, "permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"], "package": "com.stably.orca.mobile", - "versionCode": 2 + "versionCode": 4 }, "plugins": [ "expo-router", diff --git a/mobile/scripts/prepare-android-release.mjs b/mobile/scripts/prepare-android-release.mjs index f01c5d1d2..e318d7c60 100644 --- a/mobile/scripts/prepare-android-release.mjs +++ b/mobile/scripts/prepare-android-release.mjs @@ -22,31 +22,12 @@ function fail(message) { process.exit(1) } -function parsePositiveInteger(value, name) { - if (!/^\d+$/.test(value)) { - fail(`${name} must be a positive integer`) - } - - const parsed = Number(value) - if (!Number.isSafeInteger(parsed) || parsed <= 0) { - fail(`${name} must be a positive integer`) - } - - return parsed -} - function validateSemver(version, name) { if (!semverPattern.test(version)) { fail(`${name} must use x.y.z format`) } } -function bumpPatchVersion(version) { - validateSemver(version, 'Current mobile version') - const [major, minor, patch] = version.split('.') - return `${major}.${minor}.${Number(patch) + 1}` -} - function writeOutput(name, value) { const outputPath = process.env.GITHUB_OUTPUT if (!outputPath) { @@ -74,10 +55,6 @@ const tagVersion = githubRef.startsWith(androidTagRefPrefix) const requestedVersion = input('MOBILE_ANDROID_RELEASE_VERSION') const bumpPatch = truthy(input('MOBILE_ANDROID_BUMP_PATCH_VERSION')) -if (requestedVersion && bumpPatch) { - fail('Use either MOBILE_ANDROID_RELEASE_VERSION or MOBILE_ANDROID_BUMP_PATCH_VERSION, not both') -} - if (tagVersion) { validateSemver(tagVersion, 'Android release tag version') } @@ -86,43 +63,36 @@ if (requestedVersion) { validateSemver(requestedVersion, 'MOBILE_ANDROID_RELEASE_VERSION') } -if (tagVersion && requestedVersion && requestedVersion !== tagVersion) { - fail('MOBILE_ANDROID_RELEASE_VERSION must match the mobile-android-v tag') +if (bumpPatch) { + fail('MOBILE_ANDROID_BUMP_PATCH_VERSION is no longer supported; commit mobile/app.json first') } -if (tagVersion && bumpPatch) { - fail('MOBILE_ANDROID_BUMP_PATCH_VERSION is only supported for manual branch runs') +if (tagVersion && tagVersion !== currentVersion) { + fail('Android release tag version must match the committed mobile app version') } -const version = - requestedVersion || tagVersion || (bumpPatch ? bumpPatchVersion(currentVersion) : currentVersion) +if (requestedVersion && requestedVersion !== currentVersion) { + fail('MOBILE_ANDROID_RELEASE_VERSION must match the committed mobile app version') +} const requestedVersionCode = input('MOBILE_ANDROID_VERSION_CODE') const bumpVersionCode = truthy(input('MOBILE_ANDROID_BUMP_VERSION_CODE')) -if (requestedVersionCode && bumpVersionCode) { - fail('Use either MOBILE_ANDROID_VERSION_CODE or MOBILE_ANDROID_BUMP_VERSION_CODE, not both') +if (requestedVersionCode || bumpVersionCode) { + // Why: Android rejects lower versionCode installs; release-only bumps leave + // committed dev builds behind shipped APKs and break local testing. + fail('Android versionCode changes must be committed in mobile/app.json before release') } -const versionCode = requestedVersionCode - ? parsePositiveInteger(requestedVersionCode, 'MOBILE_ANDROID_VERSION_CODE') - : bumpVersionCode || version !== currentVersion - ? currentVersionCode + 1 - : currentVersionCode - -expo.version = version -android.versionCode = versionCode -fs.writeFileSync(appConfigPath, `${JSON.stringify(config, null, 2)}\n`) - -const tag = `mobile-android-v${version}` +const tag = `mobile-android-v${currentVersion}` const publishRelease = githubRef.startsWith(androidTagRefPrefix) || truthy(input('MOBILE_ANDROID_PUBLISH_RELEASE')) -writeOutput('version', version) -writeOutput('android_version_code', String(versionCode)) +writeOutput('version', currentVersion) +writeOutput('android_version_code', String(currentVersionCode)) writeOutput('tag', tag) writeOutput('publish_release', publishRelease ? 'true' : 'false') -console.log(`Prepared Orca Mobile Android ${version} (${versionCode})`) +console.log(`Prepared Orca Mobile Android ${currentVersion} (${currentVersionCode})`) console.log(`Release tag: ${tag}`) console.log(`Publish GitHub Release: ${publishRelease ? 'yes' : 'no'}`) diff --git a/mobile/src/mobile-release/prepare-android-release-script.test.ts b/mobile/src/mobile-release/prepare-android-release-script.test.ts new file mode 100644 index 000000000..668b4689b --- /dev/null +++ b/mobile/src/mobile-release/prepare-android-release-script.test.ts @@ -0,0 +1,92 @@ +import { execFileSync, spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' + +const scriptPath = fileURLToPath( + new URL('../../scripts/prepare-android-release.mjs', import.meta.url) +) + +const appConfig = { + expo: { + version: '0.0.22', + android: { + versionCode: 4 + } + } +} + +let tempDirs: string[] = [] + +function createAppConfig() { + const dir = mkdtempSync(join(tmpdir(), 'orca-android-release-')) + tempDirs.push(dir) + const configPath = join(dir, 'app.json') + const contents = `${JSON.stringify(appConfig, null, 2)}\n` + writeFileSync(configPath, contents) + return { configPath, contents } +} + +describe('prepare Android release script', () => { + afterEach(() => { + for (const dir of tempDirs) { + rmSync(dir, { force: true, recursive: true }) + } + tempDirs = [] + }) + + it('uses committed Android release identity without mutating app config', () => { + const { configPath, contents } = createAppConfig() + + const output = execFileSync(process.execPath, [scriptPath], { + encoding: 'utf8', + env: { + ...process.env, + MOBILE_APP_CONFIG_PATH: configPath, + MOBILE_ANDROID_PUBLISH_RELEASE: 'true' + } + }) + + expect(output).toContain('Prepared Orca Mobile Android 0.0.22 (4)') + expect(output).toContain('Release tag: mobile-android-v0.0.22') + expect(readFileSync(configPath, 'utf8')).toBe(contents) + }) + + it('rejects release-only Android versionCode bumps', () => { + const { configPath } = createAppConfig() + + const result = spawnSync(process.execPath, [scriptPath], { + encoding: 'utf8', + env: { + ...process.env, + MOBILE_APP_CONFIG_PATH: configPath, + MOBILE_ANDROID_BUMP_VERSION_CODE: 'true' + } + }) + + expect(result.status).toBe(1) + expect(result.stderr).toContain( + 'Android versionCode changes must be committed in mobile/app.json before release' + ) + }) + + it('rejects release versions that do not match committed app config', () => { + const { configPath } = createAppConfig() + + const result = spawnSync(process.execPath, [scriptPath], { + encoding: 'utf8', + env: { + ...process.env, + MOBILE_APP_CONFIG_PATH: configPath, + MOBILE_ANDROID_RELEASE_VERSION: '0.0.23' + } + }) + + expect(result.status).toBe(1) + expect(result.stderr).toContain( + 'MOBILE_ANDROID_RELEASE_VERSION must match the committed mobile app version' + ) + }) +})