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:
@@ -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 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: {
|
||||
|
||||
@@ -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: {
|
||||
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}"],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user