diff --git a/.codex/skills/generate-release/scripts/set_version.mjs b/.codex/skills/generate-release/scripts/set_version.mjs index 8e67b4c..b2405e5 100755 --- a/.codex/skills/generate-release/scripts/set_version.mjs +++ b/.codex/skills/generate-release/scripts/set_version.mjs @@ -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(/CFBundleShortVersionString<\/key>\s*([^<]+)<\/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( + /(CFBundleShortVersionString<\/key>\s*)([^<]+)(<\/string>)/, + `$1${desired}$3`, +); +const widgetInfoRaw = state.widgetInfoRaw.replace( + /(CFBundleShortVersionString<\/key>\s*)([^<]+)(<\/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}`); diff --git a/apps/mobile/android/app/build.gradle b/apps/mobile/android/app/build.gradle index c5e84d5..bd98d12 100644 --- a/apps/mobile/android/app/build.gradle +++ b/apps/mobile/android/app/build.gradle @@ -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 { diff --git a/apps/mobile/android/app/src/main/AndroidManifest.xml b/apps/mobile/android/app/src/main/AndroidManifest.xml index 975c231..a35260b 100644 --- a/apps/mobile/android/app/src/main/AndroidManifest.xml +++ b/apps/mobile/android/app/src/main/AndroidManifest.xml @@ -27,9 +27,14 @@ - + + + + - + + + @@ -44,6 +49,12 @@ + + + + + + @@ -53,4 +64,4 @@ - + \ No newline at end of file diff --git a/apps/mobile/android/app/src/main/res/values/strings.xml b/apps/mobile/android/app/src/main/res/values/strings.xml index f6350f7..8ef60c0 100644 --- a/apps/mobile/android/app/src/main/res/values/strings.xml +++ b/apps/mobile/android/app/src/main/res/values/strings.xml @@ -3,4 +3,5 @@ contain false See your shared drawing with your partner. + 1.0.7 \ No newline at end of file diff --git a/apps/mobile/app.config.js b/apps/mobile/app.config.js index 761a5b2..4615709 100644 --- a/apps/mobile/app.config.js +++ b/apps/mobile/app.config.js @@ -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) diff --git a/apps/mobile/app.json b/apps/mobile/app.json index a521ad7..67e7111 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -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", diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index d508c20..0e4efe1 100755 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -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(); diff --git a/apps/mobile/ios/Sauci.xcodeproj/project.pbxproj b/apps/mobile/ios/Sauci.xcodeproj/project.pbxproj index f6de4c5..e1402af 100644 --- a/apps/mobile/ios/Sauci.xcodeproj/project.pbxproj +++ b/apps/mobile/ios/Sauci.xcodeproj/project.pbxproj @@ -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"; diff --git a/apps/mobile/ios/Sauci/Info.plist b/apps/mobile/ios/Sauci/Info.plist index 8e27983..b14b11a 100644 --- a/apps/mobile/ios/Sauci/Info.plist +++ b/apps/mobile/ios/Sauci/Info.plist @@ -21,7 +21,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.0.6 + 1.0.7 CFBundleSignature ???? CFBundleURLTypes diff --git a/apps/mobile/ios/Sauci/Sauci.entitlements b/apps/mobile/ios/Sauci/Sauci.entitlements index c9b13a8..543c74e 100644 --- a/apps/mobile/ios/Sauci/Sauci.entitlements +++ b/apps/mobile/ios/Sauci/Sauci.entitlements @@ -8,9 +8,13 @@ Default + com.apple.developer.associated-domains + + applinks:sauci.app + com.apple.security.application-groups group.com.sauci.app - \ No newline at end of file + diff --git a/apps/mobile/ios/Sauci/Supporting/Expo.plist b/apps/mobile/ios/Sauci/Supporting/Expo.plist index 750be02..64298e4 100644 --- a/apps/mobile/ios/Sauci/Supporting/Expo.plist +++ b/apps/mobile/ios/Sauci/Supporting/Expo.plist @@ -4,9 +4,49 @@ EXUpdatesCheckOnLaunch ALWAYS + EXUpdatesCodeSigningCertificate + -----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----- + + EXUpdatesCodeSigningMetadata + + keyid + main + alg + rsa-v1_5-sha256 + EXUpdatesEnabled - + EXUpdatesLaunchWaitMs - 0 + 5000 + EXUpdatesRequestHeaders + + expo-channel-name + production + expo-app-id + 9a66f3c2-fda7-416f-87e2-6db833b32e9d + xprem-branch + + + EXUpdatesRuntimeVersion + 1.0.7 + EXUpdatesURL + https://ota.apps.jbrunton.co.uk/manifest \ No newline at end of file diff --git a/apps/mobile/src/__tests__/appConfig.test.ts b/apps/mobile/src/__tests__/appConfig.test.ts new file mode 100644 index 0000000..43a995b --- /dev/null +++ b/apps/mobile/src/__tests__/appConfig.test.ts @@ -0,0 +1,46 @@ +const configPath = require.resolve("../../app.config"); + +type UpdateConfig = { + updates: { + codeSigningCertificate?: string; + codeSigningMetadata?: Record; + }; +}; + +function resolveConfig(environment: Record): 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(); + }); +}); diff --git a/apps/mobile/src/__tests__/stores/authStore.test.ts b/apps/mobile/src/__tests__/stores/authStore.test.ts index 7b287d9..bf3a242 100644 --- a/apps/mobile/src/__tests__/stores/authStore.test.ts +++ b/apps/mobile/src/__tests__/stores/authStore.test.ts @@ -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, + }); + }); }); diff --git a/apps/mobile/src/store/authStore.ts b/apps/mobile/src/store/authStore.ts index 9614449..1805e74 100644 --- a/apps/mobile/src/store/authStore.ts +++ b/apps/mobile/src/store/authStore.ts @@ -88,7 +88,7 @@ export const useAuthStore = create((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((set, get) => ({ user: null, couple: null, partner: null, + sealedCount: 0, isAuthenticated: false, isAnonymous: false, isLoading: false, @@ -171,7 +172,7 @@ export const useAuthStore = create((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((set, get) => ({ void syncTimezone(userId); }, })); - diff --git a/apps/mobile/targets/widget/Info.plist b/apps/mobile/targets/widget/Info.plist index 059cefe..59e959d 100644 --- a/apps/mobile/targets/widget/Info.plist +++ b/apps/mobile/targets/widget/Info.plist @@ -9,7 +9,7 @@ CFBundleVersion 45 - CFBundleShortVersionString - 1.0.6 + CFBundleShortVersionString + 1.0.7 diff --git a/docs/mobile-ota.md b/docs/mobile-ota.md index 2c41ec1..281bbbf 100644 --- a/docs/mobile-ota.md +++ b/docs/mobile-ota.md @@ -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: " \ -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.