Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/mobile-eas-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ name: Mobile EAS Production
# in the same OS/pnpm as the EAS build; a macOS `eas build` computes a different
# fingerprint (platform-specific deps + pnpm version) and errors. On this Linux
# runner, with corepack pinning pnpm 10.24 in eas.json, local == build.
# The app's major version is part of that fingerprint
# (apps/mobile/fingerprint.config.js), so a production OTA only reaches store
# binaries of the same major; a new major reaches users with its store build.
on:
workflow_dispatch:
inputs:
Expand Down
23 changes: 23 additions & 0 deletions apps/mobile/fingerprint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// @ts-check
const { readAppMajorVersionFromProject } = require("./scripts/app-major-version.cjs");

// Keep each major version's over-the-air updates on its own binaries: a new
// major reaches users only once its store build ships.
//
// Expo SDK 58's fingerprint drops the app version by default, so without this
// source binaries of different majors would share a runtime version whenever
// native code is unchanged. SDK 57 still hashes the full version, which makes
// this redundant (but harmless) until the SDK 58 upgrade.
//
// The fingerprint loader swallows errors thrown here and silently falls back to
// an empty config, so a parse failure cannot fail a build. The unit test in
// scripts/app-major-version.test.mjs is the guard that keeps this readable.
module.exports = {
extraSources: [
{
type: "contents",
id: "appMajorVersion",
contents: readAppMajorVersionFromProject(__dirname),
},
],
};
34 changes: 34 additions & 0 deletions apps/mobile/scripts/app-major-version.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// @ts-check
const fs = require("node:fs");
const path = require("node:path");

// The app version is the top-level `version: "X.Y.Z"` property of the Expo
// config. app.config.ts is TypeScript, which the fingerprint loader cannot
// require, so it is read as text. Exactly one match is required: a second
// two-space `version:` line would make the source of truth ambiguous.
const APP_VERSION_PATTERN = /^ {2}version: "(\d+)\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?",?$/gm;

/**
* @param {string} appConfigSource Contents of app.config.ts.
* @returns {string} The app's major version, such as "1".
*/
function readAppMajorVersion(appConfigSource) {
const matches = [...appConfigSource.matchAll(APP_VERSION_PATTERN)];
const majorVersion = matches.length === 1 ? matches[0]?.[1] : undefined;
if (!majorVersion) {
throw new Error(
`Expected exactly one top-level semver \`version: "X.Y.Z"\` in app.config.ts, found ${matches.length}.`,
);
}
return majorVersion;
}

/**
* @param {string} mobileRoot Directory containing app.config.ts.
* @returns {string}
*/
function readAppMajorVersionFromProject(mobileRoot) {
return readAppMajorVersion(fs.readFileSync(path.join(mobileRoot, "app.config.ts"), "utf8"));
}

module.exports = { readAppMajorVersion, readAppMajorVersionFromProject };
45 changes: 45 additions & 0 deletions apps/mobile/scripts/app-major-version.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import * as NodeModule from "node:module";
import { describe, expect, it } from "vitest";

import { readAppMajorVersion, readAppMajorVersionFromProject } from "./app-major-version.cjs";

const require = NodeModule.createRequire(import.meta.url);
const mobileRoot = new URL("..", import.meta.url).pathname;

const appConfig = (version) => `const config: ExpoConfig = {
name: variant.appName,
version: "${version}",
runtimeVersion: {
policy: process.env.MOBILE_VERSION_POLICY ?? "fingerprint",
},
};`;

describe("app major version", () => {
it("is stable across minor and patch releases and changes with the major", () => {
expect(readAppMajorVersion(appConfig("1.0.1"))).toBe("1");
expect(readAppMajorVersion(appConfig("1.4.0"))).toBe("1");
expect(readAppMajorVersion(appConfig("1.0.2-beta.1"))).toBe("1");
expect(readAppMajorVersion(appConfig("2.0.0"))).toBe("2");
expect(readAppMajorVersion(appConfig("10.3.7"))).toBe("10");
});

it("rejects a missing, non-semver or ambiguous version", () => {
expect(() => readAppMajorVersion("const config = {};")).toThrow(/found 0/);
expect(() => readAppMajorVersion(appConfig("1.0"))).toThrow(/found 0/);
expect(() => readAppMajorVersion(`${appConfig("1.0.1")}\n${appConfig("2.0.0")}`)).toThrow(
/found 2/,
);
});

// The fingerprint loader swallows config errors, so this is what fails CI if
// app.config.ts stops matching and the major silently leaves the fingerprint.
it("reads the real app.config.ts and feeds it to the fingerprint config", () => {
const majorVersion = readAppMajorVersionFromProject(mobileRoot);
expect(majorVersion).toMatch(/^\d+$/);

const fingerprintConfig = require("../fingerprint.config.js");
expect(fingerprintConfig).toEqual({
extraSources: [{ type: "contents", id: "appMajorVersion", contents: majorVersion }],
});
});
});
5 changes: 4 additions & 1 deletion docs/internals/mobile-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ after initialization succeeds.
Every Pylon variant keeps the fingerprint runtime-version policy and its Pylon URL scheme. A
dependency patch can change the native fingerprint even when its implementation changes only
JavaScript, so build a compatible binary before publishing an over-the-air update for a changed
fingerprint.
fingerprint. [`fingerprint.config.js`](../../apps/mobile/fingerprint.config.js) also hashes the
app's major version, so an update never crosses a major-version boundary even when native code is
unchanged. The fingerprint loader ignores errors in that file, so its unit test is what keeps the
version readable from `app.config.ts`.

Modules exposed through `file:` dependencies (such as `t3-markdown-text`) under
`apps/mobile/modules/` are copied by pnpm into
Expand Down
3 changes: 2 additions & 1 deletion knip.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,10 @@
"ignoreIssues": { "src/components/ui/*.tsx": ["exports", "nsExports", "duplicates"] },
},
"apps/mobile": {
// Expo loads plugins by string; Metro embeds the browser entry in the native WebView.
// Expo loads plugins and the fingerprint config by string; Metro embeds the browser entry in the native WebView.
"entry": [
"index.ts!",
"fingerprint.config.js",
"plugins/*.cjs",
"scripts/generate-device-stream.mts",
"src/features/devices/device-stream.browser.ts!",
Expand Down
Loading