fix(mobile): prepare v1.0.7 OTA release

This commit is contained in:
jjbrunton committed 2026-08-30 20:41:08 +01:00
1 parent 2664791233
commit db5980c9bd
16 files changed
+221 -61

No files matched your search

@@ -10,6 +10,8 @@ const files = {
app: path.join(repoRoot, "apps/mobile/app.json"),
android: path.join(repoRoot, "apps/mobile/android/app/build.gradle"),
ios: path.join(repoRoot, "apps/mobile/ios/Sauci.xcodeproj/project.pbxproj"),
iosInfo: path.join(repoRoot, "apps/mobile/ios/Sauci/Info.plist"),
widgetInfo: path.join(repoRoot, "apps/mobile/targets/widget/Info.plist"),
};
const semverPattern = /^([0-9]+)\.([0-9]+)\.([0-9]+)$/;
@@ -29,6 +31,8 @@ function readState() {
const appRaw = fs.readFileSync(files.app, "utf8");
const androidRaw = fs.readFileSync(files.android, "utf8");
const iosRaw = fs.readFileSync(files.ios, "utf8");
const iosInfoRaw = fs.readFileSync(files.iosInfo, "utf8");
const widgetInfoRaw = fs.readFileSync(files.widgetInfo, "utf8");
const appVersion = JSON.parse(appRaw).expo?.version;
const androidVersions = [...androidRaw.matchAll(/\bversionName\s+["']([^"']+)["']/g)].map(
(match) => match[1],
@@ -36,18 +40,35 @@ function readState() {
const iosVersions = [...iosRaw.matchAll(/\bMARKETING_VERSION\s*=\s*([^;]+);/g)].map(
(match) => match[1].trim(),
);
const plistVersion = (raw, label) => {
const versions = [...raw.matchAll(/<key>CFBundleShortVersionString<\/key>\s*<string>([^<]+)<\/string>/g)].map(
(match) => match[1],
);
if (versions.length !== 1) fail(`expected exactly one ${label} CFBundleShortVersionString`);
return versions[0];
};
const iosInfoVersion = plistVersion(iosInfoRaw, "iOS app Info.plist");
const widgetInfoVersion = plistVersion(widgetInfoRaw, "iOS widget Info.plist");
if (typeof appVersion !== "string") fail("apps/mobile/app.json has no expo.version");
if (androidVersions.length !== 1) fail("expected exactly one Android versionName");
if (iosVersions.length === 0) fail("found no iOS MARKETING_VERSION values");
const versions = [appVersion, ...androidVersions, ...iosVersions];
const versions = [appVersion, ...androidVersions, ...iosVersions, iosInfoVersion, widgetInfoVersion];
versions.forEach((version) => parseVersion(version, `configured version ${version}`));
if (new Set(versions).size !== 1) {
fail(`public versions disagree: ${versions.join(", ")}`);
}
return { appRaw, androidRaw, iosRaw, current: appVersion, iosCount: iosVersions.length };
return {
appRaw,
androidRaw,
iosRaw,
iosInfoRaw,
widgetInfoRaw,
current: appVersion,
iosCount: iosVersions.length,
};
}
function nextVersion(current, bump) {
@@ -98,8 +119,18 @@ const iosRaw = state.iosRaw.replace(
/(\bMARKETING_VERSION\s*=\s*)([^;]+)(;)/g,
`$1${desired}$3`,
);
const iosInfoRaw = state.iosInfoRaw.replace(
/(<key>CFBundleShortVersionString<\/key>\s*<string>)([^<]+)(<\/string>)/,
`$1${desired}$3`,
);
const widgetInfoRaw = state.widgetInfoRaw.replace(
/(<key>CFBundleShortVersionString<\/key>\s*<string>)([^<]+)(<\/string>)/,
`$1${desired}$3`,
);
fs.writeFileSync(files.app, appRaw);
fs.writeFileSync(files.android, androidRaw);
fs.writeFileSync(files.ios, iosRaw);
fs.writeFileSync(files.iosInfo, iosInfoRaw);
fs.writeFileSync(files.widgetInfo, widgetInfoRaw);
console.log(`updated public version ${state.current} -> ${desired}`);
+1 -1
View File
@@ -93,7 +93,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 48
versionName "1.0.6"
versionName "1.0.7"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
}
signingConfigs {
@@ -27,9 +27,14 @@
<meta-data android:name="com.google.firebase.messaging.default_notification_icon" android:resource="@drawable/notification_icon"/>
<meta-data android:name="expo.modules.notifications.default_notification_color" android:resource="@color/notification_icon_color"/>
<meta-data android:name="expo.modules.notifications.default_notification_icon" android:resource="@drawable/notification_icon"/>
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
<meta-data android:name="expo.modules.updates.CODE_SIGNING_CERTIFICATE" android:value="-----BEGIN CERTIFICATE-----&#xA;MIICzTCCAbWgAwIBAgIJAPrPjxnUOGisMA0GCSqGSIb3DQEBCwUAMBAxDjAMBgNV&#xA;BAMTBVNhdWNpMB4XDTI2MDgzMDE4MTAzMFoXDTM2MDgzMDE4MTAzMFowEDEOMAwG&#xA;A1UEAxMFU2F1Y2kwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDWiUac&#xA;8i9bIbNZjv8iunVr6kqlgb9vea98JHz/A83biyszg0+GpmLYsr4zV2F7A6RitiIx&#xA;SOML+wVedyD0F2quWccBGXinvMAds0TxgIOjQfP+8+VJ/duDjveHvn+OFW4s7XxW&#xA;SY7TK1ufDcPWo/f5XzfqtBveug49zv3/F3MwfmMUbEY/zvH7nayPGJNuSyf3wISn&#xA;JIt0BsHNhWykRXCzjaF2fy3nAebOyezbtbapCjUlxPENVtBCzBQ7C9kaQXCBw5mV&#xA;hxhJlOxHWXpHJgDLHpev8A/CWQRh67tsevjKi6R6SDlf0R0xSbDJTElx8goWOOQW&#xA;VO9aDqI+oH0KpWfnAgMBAAGjKjAoMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8E&#xA;DDAKBggrBgEFBQcDAzANBgkqhkiG9w0BAQsFAAOCAQEAyUc20bIRCxoBTJg84mPl&#xA;2nFDWSaAfzgo0K0b6x2T1suuq4LAOUgzBqJISrQAALd0besK1qUZSAMhjQu1wPZT&#xA;MHuWRMxQHfOkyHMYUfOoPMS4bE+4K0WjzfoVt3y1A+oxhcIAQUUGyNgNmebfEN07&#xA;JlX0B8UI8aIMjyfpsIDgZx1piHKwittGy/V4MPOLbmT2BBwS9BELcmWE4CwWbfeo&#xA;JM4lXY0hk3M/GTstRYaUKiwBWEU+z1PdAmWAgrtxpVnXN+oaToWh0WbVkbF1K6r5&#xA;3oBGFTL7kT+Wogg9GK04XoAREw0jZJ/k77oJx68lGVBNiB+Ue2KqX0H/0cf2CslY&#xA;Ug==&#xA;-----END CERTIFICATE-----&#xA;"/>
<meta-data android:name="expo.modules.updates.CODE_SIGNING_METADATA" android:value="{&quot;keyid&quot;:&quot;main&quot;,&quot;alg&quot;:&quot;rsa-v1_5-sha256&quot;}"/>
<meta-data android:name="expo.modules.updates.ENABLED" android:value="true"/>
<meta-data android:name="expo.modules.updates.EXPO_RUNTIME_VERSION" android:value="@string/expo_runtime_version"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="5000"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATE_URL" android:value="https://ota.apps.jbrunton.co.uk/manifest"/>
<meta-data android:name="expo.modules.updates.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY" android:value="{&quot;expo-channel-name&quot;:&quot;production&quot;,&quot;expo-app-id&quot;:&quot;9a66f3c2-fda7-416f-87e2-6db833b32e9d&quot;,&quot;xprem-branch&quot;:&quot;&quot;}"/>
<service android:name="com.reactnativeandroidwidget.RNWidgetCollectionService" android:permission="android.permission.BIND_REMOTEVIEWS"/>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
<intent-filter>
@@ -44,6 +49,12 @@
<data android:scheme="com.sauci.app"/>
<data android:scheme="exp+sauci"/>
</intent-filter>
<intent-filter android:autoVerify="true" data-generated="true">
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="https" android:host="sauci.app" android:pathPrefix="/join"/>
<category android:name="android.intent.category.BROWSABLE"/>
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
</activity>
<receiver android:name=".widget.LiveDraw" android:exported="true" android:label="Live Draw">
<intent-filter>
@@ -53,4 +64,4 @@
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_livedraw"/>
</receiver>
</application>
</manifest>
</manifest>
@@ -3,4 +3,5 @@
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
<string name="widget_livedraw_description" translatable="false">See your shared drawing with your partner.</string>
<string name="expo_runtime_version">1.0.7</string>
</resources>
+7
View File
@@ -48,6 +48,13 @@ const releaseChannel = process.env.RELEASE_CHANNEL || 'development';
*/
const codeSigningDisabled = Boolean(process.env.DISABLE_CODE_SIGNING);
if (releaseChannel === 'production' && codeSigningDisabled) {
throw new Error(
'RELEASE_CHANNEL=production does not permit DISABLE_CODE_SIGNING. ' +
'Production builds must embed the XPREM code-signing certificate and metadata.',
);
}
function assertUpdateConfigIsReal() {
const certificatePath = path.join(__dirname, CODE_SIGNING_CERTIFICATE_PATH);
const certificate = fs.existsSync(certificatePath)
+1 -1
View File
@@ -2,7 +2,7 @@
"expo": {
"name": "Sauci",
"slug": "sauci",
"version": "1.0.6",
"version": "1.0.7",
"orientation": "portrait",
"icon": "./assets/icon.png",
"scheme": "app.sauci",
+14 -18
View File
@@ -115,17 +115,15 @@ export default function RootLayout() {
// Fetch user on mount
fetchUser();
// Handle initial URL if app was opened via deep link
const handleDeepLink = async (url: string | null) => {
console.log('[DeepLink] handleDeepLink called with URL:', url);
if (!url) return;
// Handle initial URL if app was opened via deep link
const handleDeepLink = async (url: string | null) => {
if (!url) return;
// Invite links (https://sauci.app/join/{code} or app.sauci://join?code={code})
// are handled separately from the Supabase auth deep link flow below.
const inviteLink = parseInviteLinkCode(url);
if (inviteLink) {
console.log('[DeepLink] Invite link detected:', inviteLink);
Events.inviteLinkOpened(inviteLink.source);
const inviteLink = parseInviteLinkCode(url);
if (inviteLink) {
Events.inviteLinkOpened(inviteLink.source);
await stashPendingInviteCode(inviteLink.code);
const { isAuthenticated, user } = useAuthStore.getState();
@@ -202,17 +200,15 @@ export default function RootLayout() {
}
};
// Get initial URL
Linking.getInitialURL().then((url) => {
console.log('[DeepLink] getInitialURL returned:', url);
handleDeepLink(url);
});
// Get initial URL
Linking.getInitialURL().then((url) => {
handleDeepLink(url);
});
// Listen for new deep links while app is open
const linkingSubscription = Linking.addEventListener("url", (event) => {
console.log('[DeepLink] URL event listener triggered with:', event.url);
handleDeepLink(event.url);
});
// Listen for new deep links while app is open
const linkingSubscription = Linking.addEventListener("url", (event) => {
handleDeepLink(event.url);
});
// Set up push notification response listener (for taps while app is running)
const notificationSubscription = setupNotificationResponseListener();
@@ -570,7 +570,7 @@
INFOPLIST_FILE = Sauci/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0.6;
MARKETING_VERSION = 1.0.7;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -599,7 +599,7 @@
INFOPLIST_FILE = Sauci/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0.6;
MARKETING_VERSION = 1.0.7;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -782,7 +782,7 @@
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
MARKETING_VERSION = 1.0.6;
MARKETING_VERSION = 1.0.7;
MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.sauci.app.LiveDrawWidget;
@@ -818,7 +818,7 @@
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
MARKETING_VERSION = 1.0.6;
MARKETING_VERSION = 1.0.7;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
+1 -1
View File
@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0.6</string>
<string>1.0.7</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
+5 -1
View File
@@ -8,9 +8,13 @@
<array>
<string>Default</string>
</array>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:sauci.app</string>
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.sauci.app</string>
</array>
</dict>
</plist>
</plist>
+42 -2
View File
@@ -4,9 +4,49 @@
<dict>
<key>EXUpdatesCheckOnLaunch</key>
<string>ALWAYS</string>
<key>EXUpdatesCodeSigningCertificate</key>
<string>-----BEGIN CERTIFICATE-----
MIICzTCCAbWgAwIBAgIJAPrPjxnUOGisMA0GCSqGSIb3DQEBCwUAMBAxDjAMBgNV
BAMTBVNhdWNpMB4XDTI2MDgzMDE4MTAzMFoXDTM2MDgzMDE4MTAzMFowEDEOMAwG
A1UEAxMFU2F1Y2kwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDWiUac
8i9bIbNZjv8iunVr6kqlgb9vea98JHz/A83biyszg0+GpmLYsr4zV2F7A6RitiIx
SOML+wVedyD0F2quWccBGXinvMAds0TxgIOjQfP+8+VJ/duDjveHvn+OFW4s7XxW
SY7TK1ufDcPWo/f5XzfqtBveug49zv3/F3MwfmMUbEY/zvH7nayPGJNuSyf3wISn
JIt0BsHNhWykRXCzjaF2fy3nAebOyezbtbapCjUlxPENVtBCzBQ7C9kaQXCBw5mV
hxhJlOxHWXpHJgDLHpev8A/CWQRh67tsevjKi6R6SDlf0R0xSbDJTElx8goWOOQW
VO9aDqI+oH0KpWfnAgMBAAGjKjAoMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8E
DDAKBggrBgEFBQcDAzANBgkqhkiG9w0BAQsFAAOCAQEAyUc20bIRCxoBTJg84mPl
2nFDWSaAfzgo0K0b6x2T1suuq4LAOUgzBqJISrQAALd0besK1qUZSAMhjQu1wPZT
MHuWRMxQHfOkyHMYUfOoPMS4bE+4K0WjzfoVt3y1A+oxhcIAQUUGyNgNmebfEN07
JlX0B8UI8aIMjyfpsIDgZx1piHKwittGy/V4MPOLbmT2BBwS9BELcmWE4CwWbfeo
JM4lXY0hk3M/GTstRYaUKiwBWEU+z1PdAmWAgrtxpVnXN+oaToWh0WbVkbF1K6r5
3oBGFTL7kT+Wogg9GK04XoAREw0jZJ/k77oJx68lGVBNiB+Ue2KqX0H/0cf2CslY
Ug==
-----END CERTIFICATE-----
</string>
<key>EXUpdatesCodeSigningMetadata</key>
<dict>
<key>keyid</key>
<string>main</string>
<key>alg</key>
<string>rsa-v1_5-sha256</string>
</dict>
<key>EXUpdatesEnabled</key>
<false/>
<true/>
<key>EXUpdatesLaunchWaitMs</key>
<integer>0</integer>
<integer>5000</integer>
<key>EXUpdatesRequestHeaders</key>
<dict>
<key>expo-channel-name</key>
<string>production</string>
<key>expo-app-id</key>
<string>9a66f3c2-fda7-416f-87e2-6db833b32e9d</string>
<key>xprem-branch</key>
<string/>
</dict>
<key>EXUpdatesRuntimeVersion</key>
<string>1.0.7</string>
<key>EXUpdatesURL</key>
<string>https://ota.apps.jbrunton.co.uk/manifest</string>
</dict>
</plist>
@@ -0,0 +1,46 @@
const configPath = require.resolve("../../app.config");
type UpdateConfig = {
updates: {
codeSigningCertificate?: string;
codeSigningMetadata?: Record<string, string>;
};
};
function resolveConfig(environment: Record<string, string>): UpdateConfig {
const previous = {
DISABLE_CODE_SIGNING: process.env.DISABLE_CODE_SIGNING,
RELEASE_CHANNEL: process.env.RELEASE_CHANNEL,
};
Object.assign(process.env, environment);
jest.resetModules();
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
return (require(configPath) as () => UpdateConfig)();
} finally {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
}
describe("Expo OTA configuration", () => {
it("rejects disabling code signing for a production build", () => {
expect(() =>
resolveConfig({ RELEASE_CHANNEL: "production", DISABLE_CODE_SIGNING: "1" }),
).toThrow("RELEASE_CHANNEL=production does not permit DISABLE_CODE_SIGNING");
});
it("keeps local development code-signing opt-out available", () => {
const config = resolveConfig({ RELEASE_CHANNEL: "development", DISABLE_CODE_SIGNING: "1" });
expect(config.updates.codeSigningCertificate).toBeUndefined();
expect(config.updates.codeSigningMetadata).toBeUndefined();
});
});
@@ -112,6 +112,7 @@ describe("authStore", () => {
});
it("clears user-scoped stores and hosted auth when the API rejects the session", async () => {
useAuthStore.setState({ sealedCount: 3 } as any);
(authClient.auth.getSession as jest.Mock).mockResolvedValueOnce({
data: { session: { user: { id: "me" } } },
});
@@ -122,6 +123,7 @@ describe("authStore", () => {
expect(useAuthStore.getState()).toMatchObject({
user: null,
sealedCount: 0,
isAuthenticated: false,
isAnonymous: false,
isLoading: false,
@@ -132,4 +134,28 @@ describe("authStore", () => {
expect(useSubscriptionStore.getState().subscription).toMatchObject({ isProUser: false });
expect(authClient.auth.signOut).toHaveBeenCalled();
});
it("clears sealed answers during an explicit sign-out", async () => {
useAuthStore.setState({ user: profile, sealedCount: 3, isAuthenticated: true } as any);
await useAuthStore.getState().signOut();
expect(useAuthStore.getState()).toMatchObject({
user: null,
sealedCount: 0,
isAuthenticated: false,
});
});
it("clears sealed answers when the auth listener clears the user", () => {
useAuthStore.setState({ user: profile, sealedCount: 3, isAuthenticated: true } as any);
useAuthStore.getState().setUser(null);
expect(useAuthStore.getState()).toMatchObject({
user: null,
sealedCount: 0,
isAuthenticated: false,
});
});
});
+3 -3
View File
@@ -88,7 +88,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
console.log("[Auth] Session rejected by API, signing out");
set({ user: null, couple: null, partner: null, isAuthenticated: false, isAnonymous: false, isLoading: false });
set({ user: null, couple: null, partner: null, sealedCount: 0, isAuthenticated: false, isAnonymous: false, isLoading: false });
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore, useQuizStore } = getOtherStores();
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
@@ -137,6 +137,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
couple: null,
partner: null,
sealedCount: 0,
isAuthenticated: false,
isAnonymous: false,
isLoading: false,
@@ -171,7 +172,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
...(user === null && { isAnonymous: false }),
isLoading: false,
// Clear couple/partner when user is null (signed out)
...(user === null && { couple: null, partner: null })
...(user === null && { couple: null, partner: null, sealedCount: 0 })
});
// Clear other stores when user signs out
if (user === null) {
@@ -211,4 +212,3 @@ export const useAuthStore = create<AuthState>((set, get) => ({
void syncTimezone(userId);
},
}));
+2 -2
View File
@@ -9,7 +9,7 @@
</dict>
<key>CFBundleVersion</key>
<string>45</string>
<key>CFBundleShortVersionString</key>
<string>1.0.6</string>
<key>CFBundleShortVersionString</key>
<string>1.0.7</string>
</dict>
</plist>
+21 -23
View File
@@ -12,10 +12,10 @@ already installed build.
## Status
The client is wired with the real server values (app id
`9a66f3c2-fda7-416f-87e2-6db833b32e9d` and the Sauci code-signing certificate,
SHA-256 fingerprint beginning `DF:C9:17:A9`). What remains is the native
prebuild and a store build; see [Server registration](#server-registration).
The client and checked-in native projects are wired with the real server values
(app id `9a66f3c2-fda7-416f-87e2-6db833b32e9d` and the Sauci code-signing
certificate, SHA-256 fingerprint beginning `DF:C9:17:A9`). A new store build is
required before updates can flow; see [Server registration](#server-registration).
## How it works
@@ -54,7 +54,7 @@ runtimeVersion: { policy: 'appVersion' }
```
The runtime version is therefore the `expo.version` field in `app.json`, for
example `1.0.6`. An update is only offered to builds whose runtime version
example `1.0.7`. An update is only offered to builds whose runtime version
matches exactly.
The practical rule: **any change that touches native code, native dependencies,
@@ -136,28 +136,27 @@ the Sauci app.
`apps/mobile/ios` and `apps/mobile/android` are committed but generated by Expo
CNG, and `docs/releasing.md` treats `expo prebuild` as a source change rather
than a release step. The native projects currently still carry the
updates-disabled defaults:
than a release step. The checked-in native projects embed the production updates
configuration:
- `ios/Sauci/Supporting/Expo.plist`: `EXUpdatesEnabled` is `false`.
- `android/app/src/main/AndroidManifest.xml`: `expo.modules.updates.ENABLED` is
`false`.
- `ios/Sauci/Supporting/Expo.plist` enables updates and contains the manifest
URL, runtime version, request headers, code-signing metadata, and public
certificate.
- `android/app/src/main/AndroidManifest.xml` enables updates and contains the
same production configuration; `android/app/src/main/res/values/strings.xml`
supplies its resolved runtime version.
The `expo-updates` config plugin writes the URL, the resolved runtime version,
the request headers, the code-signing metadata, and the certificate contents into
these files. Those values embed the real app id and certificate, so the native
projects are deliberately left untouched until both land.
With the real app id and certificate now committed, regenerate and commit the
native projects as a reviewed source change:
The `expo-updates` config plugin wrote those values from the dynamic config as a
reviewed source change. When future changes intentionally affect native config,
regenerate and review the native diff separately:
```bash
cd apps/mobile
RELEASE_CHANNEL=production npx expo prebuild
git diff # expect only expo-updates related native changes
git diff # retain only reviewed expo-updates native changes
```
A build produced before that prebuild will not check for updates.
A build produced before this native source change will not check for updates.
## Publishing an update
@@ -199,7 +198,7 @@ production deploy and none of the review latency.
curl -sD - "https://ota.apps.jbrunton.co.uk/manifest" \
-H "expo-app-id: <xprem-app-id>" \
-H "expo-channel-name: production" \
-H "expo-runtime-version: 1.0.6" \
-H "expo-runtime-version: 1.0.7" \
-H "expo-platform: ios"
```
@@ -238,6 +237,5 @@ documented in the homelab repo (`unraid/docs/xprem-ota.md`).
| Publish token | `XPREM_SAUCI_API_KEY` in the homelab repo `.env`; export as `EOO_TOKEN` when publishing |
| Channels | `staging` and `production` created server-side, mapped 1:1 to branches of the same names. `development` intentionally has no server channel — dev clients run with `DISABLE_CODE_SIGNING=1` against the packager |
Remaining before updates flow: run the prebuild described in
[Native projects](#native-projects), commit the native diff, and cut a new store
build. Only builds produced after that point can receive updates.
Remaining before updates flow: cut a new store build from this checked-in native
source. Only builds produced after that point can receive updates.