diff --git a/docs/release.md b/docs/release.md index b5ffa6296..f35296c42 100644 --- a/docs/release.md +++ b/docs/release.md @@ -82,7 +82,7 @@ npm run release:patch npm run release:minor ``` -This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no `release-mobile.yml` in this repo. +This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no mobile-release workflow under `.github/workflows`. After the stable release succeeds, move npm's `beta` pointer to the new stable version for every published package. This changes dist-tags only; do not @@ -129,7 +129,7 @@ npm run release:promote # Promote X.Y.Z-beta.N to stable X.Y.Z - Beta tags are published GitHub prereleases like `v0.1.41-beta.1` - Betas publish npm packages with `--tag beta`, so `npm install @getpaseo/cli@beta` opts in while plain `npm install @getpaseo/cli` stays on `latest` -- Betas publish desktop assets and APKs for testing, but they do not trigger the production web/mobile release flows +- Betas publish desktop assets and APKs for testing. They also build iOS, upload it to TestFlight, add it to the `Paseo Beta` external group, and submit it for Beta App Review. They do not submit mobile builds to the production stores. - `release:promote` creates a fresh stable tag like `v0.1.41`; the final release never reuses the beta tag - Desktop assets now come from the Electron package at `packages/desktop` - Beta releases use Electron's `beta` update channel. Users on the stable channel only receive stable releases; users on the beta channel receive beta releases and the final stable release when it is published. @@ -238,9 +238,11 @@ iOS and Android store builds are not in `.github/workflows`. They are triggered - **iOS (TestFlight + App Store)** — EAS builds with profile `production`, uploads to TestFlight, and a Fastlane lane submits the build for App Store review. - **Android APK (GitHub Release asset)** — separate, via `.github/workflows/android-apk-release.yml`. This is the only Android-related workflow that lives in this repo. -EAS uses the local app version source. `packages/app/app.config.js` derives Android `versionCode` and iOS `buildNumber` from the package version as `major * 1_000_000 + minor * 1_000 + patch`, ignoring prerelease metadata. Rebuilding the same tag produces the same native build number; if a store has already accepted a binary and you need a different binary, cut a new patch instead of relying on EAS remote auto-increment. +EAS uses the local app version source. `packages/app/app.config.js` derives the native version from the package version. Android `versionCode` is `major * 1_000_000 + minor * 1_000 + patch`. iOS reserves 1,000 build slots per app version: beta `N` uses slot `N`, and stable uses slot `999`. For example, `0.2.6-beta.2` appears in App Store Connect as version `0.2.6` build `2006002`; stable uses build `2006999`. Rebuilding the same tag produces the same native build number; if a store has already accepted a binary and you need a different binary, cut the next beta or patch instead of relying on EAS remote auto-increment. -There is no `release-mobile.yml` in this repo. Earlier versions of these docs referenced one — that workflow was removed and the EAS GitHub app handles tag triggering directly. +Beta tags run `Release iOS Beta`. The workflow uploads the build to TestFlight, distributes it to the persistent `Paseo Beta` external group, and submits it for Beta App Review. Testers and the group are managed once in App Store Connect; releases require no dashboard action. + +There is no mobile-release workflow under `.github/workflows`. The EAS GitHub app reads the workflows under `packages/app/.eas/workflows` and handles tag triggering directly. ### Watching mobile builds from the terminal diff --git a/packages/app/.eas/workflows/release-ios-beta.yml b/packages/app/.eas/workflows/release-ios-beta.yml new file mode 100644 index 000000000..54c1c4be2 --- /dev/null +++ b/packages/app/.eas/workflows/release-ios-beta.yml @@ -0,0 +1,25 @@ +name: Release iOS Beta + +on: + push: + tags: + - "v*-beta.*" + +jobs: + build_ios: + name: Build iOS beta + type: build + params: + platform: ios + profile: production + + distribute_ios_beta: + name: Distribute iOS beta + needs: [build_ios] + type: testflight + params: + build_id: ${{ needs.build_ios.outputs.build_id }} + profile: production + external_groups: + - Paseo Beta + submit_beta_review: true diff --git a/packages/app/app.config.js b/packages/app/app.config.js index 599d549cf..b2f2a53d8 100644 --- a/packages/app/app.config.js +++ b/packages/app/app.config.js @@ -3,6 +3,7 @@ const path = require("node:path"); const pkg = require("./package.json"); const withAndroidProfileable = require("./plugins/with-android-profileable"); const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking"); +const { getNativeReleaseVersion } = require("./native-release-version"); const appVariant = process.env.APP_VARIANT ?? "production"; const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1"; const isProfileBuild = process.env.PASEO_PROFILE_BUILD === "1"; @@ -47,30 +48,6 @@ const buildProfile = isFdroidBuild ], }; -function getNativeBuildVersionCode(version) { - const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version); - if (!match) { - throw new Error(`Cannot derive Android versionCode from non-semver version: ${version}`); - } - - const [, majorText, minorText, patchText] = match; - const major = Number(majorText); - const minor = Number(minorText); - const patch = Number(patchText); - - if (minor > 999 || patch > 999) { - throw new Error(`Cannot derive collision-free Android versionCode from version: ${version}`); - } - - const versionCode = major * 1_000_000 + minor * 1_000 + patch; - - if (!Number.isSafeInteger(versionCode) || versionCode <= 0 || versionCode > 2_100_000_000) { - throw new Error(`Derived Android versionCode is out of range: ${versionCode}`); - } - - return versionCode; -} - function resolveSecretFile(params) { const fromEnv = process.env[params.envKey]; if (typeof fromEnv === "string" && fromEnv.trim().length > 0) { @@ -113,13 +90,13 @@ const variants = { }; const variant = variants[appVariant] ?? variants.production; -const nativeBuildVersionCode = getNativeBuildVersionCode(pkg.version); +const nativeReleaseVersion = getNativeReleaseVersion(pkg.version); export default { expo: { name: variant.name, slug: "voice-mobile", - version: pkg.version, + version: nativeReleaseVersion.appVersion, orientation: "portrait", icon: "./assets/images/icon.png", scheme: "paseo", @@ -135,7 +112,7 @@ export default { ...(variant.googleServiceInfoPlist ? { googleServicesFile: variant.googleServiceInfoPlist } : {}), - buildNumber: String(nativeBuildVersionCode), + buildNumber: nativeReleaseVersion.iosBuildNumber, }, android: { adaptiveIcon: { @@ -149,7 +126,7 @@ export default { usesCleartextTraffic: true, permissions: buildProfile.androidPermissions, package: variant.packageId, - versionCode: nativeBuildVersionCode, + versionCode: nativeReleaseVersion.androidVersionCode, ...(variant.googleServicesFile ? { googleServicesFile: variant.googleServicesFile } : {}), }, web: { diff --git a/packages/app/native-release-version.js b/packages/app/native-release-version.js new file mode 100644 index 000000000..323136d4b --- /dev/null +++ b/packages/app/native-release-version.js @@ -0,0 +1,41 @@ +const versionPattern = /^(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?$/; +const stableIosBuildSlot = 999; + +function getNativeReleaseVersion(version) { + const match = versionPattern.exec(version); + if (!match) { + throw new Error(`Cannot derive native release version from unsupported version: ${version}`); + } + + const [, majorText, minorText, patchText, betaText] = match; + const major = Number(majorText); + const minor = Number(minorText); + const patch = Number(patchText); + const betaNumber = betaText === undefined ? null : Number(betaText); + + if (minor > 999 || patch > 999) { + throw new Error(`Cannot derive collision-free native version from: ${version}`); + } + if (betaNumber !== null && (betaNumber < 1 || betaNumber >= stableIosBuildSlot)) { + throw new Error(`iOS beta number must be between 1 and 998: ${version}`); + } + + const versionCode = major * 1_000_000 + minor * 1_000 + patch; + if (!Number.isSafeInteger(versionCode) || versionCode <= 0 || versionCode > 2_100_000_000) { + throw new Error(`Derived Android versionCode is out of range: ${versionCode}`); + } + + const iosBuildSlot = betaNumber ?? stableIosBuildSlot; + const iosBuildNumber = versionCode * 1_000 + iosBuildSlot; + if (!Number.isSafeInteger(iosBuildNumber)) { + throw new Error(`Derived iOS buildNumber is out of range: ${iosBuildNumber}`); + } + + return { + appVersion: `${major}.${minor}.${patch}`, + androidVersionCode: versionCode, + iosBuildNumber: String(iosBuildNumber), + }; +} + +module.exports = { getNativeReleaseVersion }; diff --git a/packages/app/native-release-version.test.ts b/packages/app/native-release-version.test.ts new file mode 100644 index 000000000..ca4f8d506 --- /dev/null +++ b/packages/app/native-release-version.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +const { getNativeReleaseVersion } = require("./native-release-version"); + +describe("native release version", () => { + it("reserves the final iOS build slot for a stable release", () => { + expect(getNativeReleaseVersion("0.2.6")).toEqual({ + appVersion: "0.2.6", + androidVersionCode: 2006, + iosBuildNumber: "2006999", + }); + }); + + it("gives each beta a unique iOS build slot under the stable app version", () => { + expect(getNativeReleaseVersion("0.2.6-beta.2")).toEqual({ + appVersion: "0.2.6", + androidVersionCode: 2006, + iosBuildNumber: "2006002", + }); + }); + + it("rejects beta numbers that consume the stable iOS build slot", () => { + expect(() => getNativeReleaseVersion("0.2.6-beta.999")).toThrow( + "iOS beta number must be between 1 and 998", + ); + }); +}); diff --git a/packages/app/vitest.config.ts b/packages/app/vitest.config.ts index 2e86596b7..da38efb81 100644 --- a/packages/app/vitest.config.ts +++ b/packages/app/vitest.config.ts @@ -22,7 +22,7 @@ export default defineConfig({ test: { name: "unit", environment: "node", - include: ["src/**/*.{test,spec}.{ts,tsx}"], + include: ["src/**/*.{test,spec}.{ts,tsx}", "native-release-version.test.ts"], setupFiles: [path.resolve(__dirname, "vitest.setup.ts")], exclude: [...configDefaults.exclude, "e2e/**", "src/**/*.browser.{test,spec}.{ts,tsx}"], },