feat(app): automate iOS beta distribution
Beta tags now reach external testers without per-release dashboard work. Reserve distinct native build slots so successive betas and the promoted stable build can coexist under one app version.
This commit is contained in:
+6
-4
@@ -82,7 +82,7 @@ npm run release:patch
|
|||||||
npm run release:minor
|
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
|
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
|
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`
|
- 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 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
|
- `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`
|
- 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.
|
- 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.
|
- **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.
|
- **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
|
### Watching mobile builds from the terminal
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -3,6 +3,7 @@ const path = require("node:path");
|
|||||||
const pkg = require("./package.json");
|
const pkg = require("./package.json");
|
||||||
const withAndroidProfileable = require("./plugins/with-android-profileable");
|
const withAndroidProfileable = require("./plugins/with-android-profileable");
|
||||||
const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking");
|
const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking");
|
||||||
|
const { getNativeReleaseVersion } = require("./native-release-version");
|
||||||
const appVariant = process.env.APP_VARIANT ?? "production";
|
const appVariant = process.env.APP_VARIANT ?? "production";
|
||||||
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
|
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
|
||||||
const isProfileBuild = process.env.PASEO_PROFILE_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) {
|
function resolveSecretFile(params) {
|
||||||
const fromEnv = process.env[params.envKey];
|
const fromEnv = process.env[params.envKey];
|
||||||
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) {
|
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) {
|
||||||
@@ -113,13 +90,13 @@ const variants = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const variant = variants[appVariant] ?? variants.production;
|
const variant = variants[appVariant] ?? variants.production;
|
||||||
const nativeBuildVersionCode = getNativeBuildVersionCode(pkg.version);
|
const nativeReleaseVersion = getNativeReleaseVersion(pkg.version);
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
expo: {
|
expo: {
|
||||||
name: variant.name,
|
name: variant.name,
|
||||||
slug: "voice-mobile",
|
slug: "voice-mobile",
|
||||||
version: pkg.version,
|
version: nativeReleaseVersion.appVersion,
|
||||||
orientation: "portrait",
|
orientation: "portrait",
|
||||||
icon: "./assets/images/icon.png",
|
icon: "./assets/images/icon.png",
|
||||||
scheme: "paseo",
|
scheme: "paseo",
|
||||||
@@ -135,7 +112,7 @@ export default {
|
|||||||
...(variant.googleServiceInfoPlist
|
...(variant.googleServiceInfoPlist
|
||||||
? { googleServicesFile: variant.googleServiceInfoPlist }
|
? { googleServicesFile: variant.googleServiceInfoPlist }
|
||||||
: {}),
|
: {}),
|
||||||
buildNumber: String(nativeBuildVersionCode),
|
buildNumber: nativeReleaseVersion.iosBuildNumber,
|
||||||
},
|
},
|
||||||
android: {
|
android: {
|
||||||
adaptiveIcon: {
|
adaptiveIcon: {
|
||||||
@@ -149,7 +126,7 @@ export default {
|
|||||||
usesCleartextTraffic: true,
|
usesCleartextTraffic: true,
|
||||||
permissions: buildProfile.androidPermissions,
|
permissions: buildProfile.androidPermissions,
|
||||||
package: variant.packageId,
|
package: variant.packageId,
|
||||||
versionCode: nativeBuildVersionCode,
|
versionCode: nativeReleaseVersion.androidVersionCode,
|
||||||
...(variant.googleServicesFile ? { googleServicesFile: variant.googleServicesFile } : {}),
|
...(variant.googleServicesFile ? { googleServicesFile: variant.googleServicesFile } : {}),
|
||||||
},
|
},
|
||||||
web: {
|
web: {
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,7 +22,7 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
name: "unit",
|
name: "unit",
|
||||||
environment: "node",
|
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")],
|
setupFiles: [path.resolve(__dirname, "vitest.setup.ts")],
|
||||||
exclude: [...configDefaults.exclude, "e2e/**", "src/**/*.browser.{test,spec}.{ts,tsx}"],
|
exclude: [...configDefaults.exclude, "e2e/**", "src/**/*.browser.{test,spec}.{ts,tsx}"],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user