diff --git a/.codex/skills/generate-release/SKILL.md b/.codex/skills/generate-release/SKILL.md index f87a95c..25e60da 100644 --- a/.codex/skills/generate-release/SKILL.md +++ b/.codex/skills/generate-release/SKILL.md @@ -43,6 +43,10 @@ Require all of the following: - the configured production API/Auth/RevenueCat environment, without printing secret values. +For an OTA release, run `npm run ota:preflight:production -w @sauci/mobile` +before any publish. This exercises the EOAS-compatible Expo export environment +without contacting the update server or uploading an update. + Do not switch branches, discard work, repair credentials, push, or weaken branch protection merely to pass preflight. Stop with the exact blocker. Never run `expo prebuild` as part of this workflow. diff --git a/apps/mobile/package.json b/apps/mobile/package.json index badd922..8e5e88c 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -19,10 +19,14 @@ "release:android": "dotenv -e .env -- eas build --platform android --profile production --local", "release:android:preview": "dotenv -e .env -- eas build --platform android --profile preview --local", "release:android:upload": "bundle exec fastlane android upload", - "ota:publish:staging": "RELEASE_CHANNEL=staging eoas publish --branch staging", - "ota:publish:production": "RELEASE_CHANNEL=production eoas publish --branch production", + "ota:preflight:staging": "node scripts/ota-publish.mjs staging --preflight", + "ota:preflight:production": "node scripts/ota-publish.mjs production --preflight", + "ota:publish:staging": "node scripts/ota-publish.mjs staging", + "ota:publish:production": "node scripts/ota-publish.mjs production", + "test:ota-publish": "node --test scripts/ota-publish.test.mjs", "ota:rollback": "eoas rollback", - "ota:doctor": "eoas doctor" + "ota:doctor:staging": "node scripts/ota-publish.mjs staging --doctor", + "ota:doctor:production": "node scripts/ota-publish.mjs production --doctor" }, "dependencies": { "@bacons/apple-targets": "^0.1.6", diff --git a/apps/mobile/scripts/ota-publish.mjs b/apps/mobile/scripts/ota-publish.mjs new file mode 100644 index 0000000..0245e39 --- /dev/null +++ b/apps/mobile/scripts/ota-publish.mjs @@ -0,0 +1,143 @@ +#!/usr/bin/env node + +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const projectDir = resolve(import.meta.dirname, '..'); +const easConfig = JSON.parse(readFileSync(join(projectDir, 'eas.json'), 'utf8')); +const profiles = { + production: { + apiOrigin: 'https://api.sauci.app', + authOrigin: 'https://ckjcrkjpmhqhiucifukx.supabase.co', + branch: 'production', + profile: 'production', + }, + staging: { + apiOrigin: 'https://api.preprod.sauci.app', + authOrigin: 'https://itbzhrvlgvdmzbnhzhyx.supabase.co', + branch: 'staging', + profile: 'preview', + }, +}; +const requiredPublicKeys = [ + 'EXPO_PUBLIC_API_URL', + 'EXPO_PUBLIC_SUPABASE_URL', + 'EXPO_PUBLIC_SUPABASE_ANON_KEY', +]; + +function fail(message) { + throw new Error(`OTA publish preflight: ${message}`); +} + +function requireCanonicalHttpsRoot(value, expected, label) { + try { + const parsed = new URL(value); + if ( + value !== expected || + parsed.protocol !== 'https:' || + parsed.origin !== expected || + parsed.username || + parsed.password || + parsed.pathname !== '/' || + parsed.search || + parsed.hash || + parsed.port + ) { + fail(`${label} must be the canonical HTTPS root ${expected}`); + } + } catch { + fail(`${label} must be the canonical HTTPS root ${expected}`); + } +} + +export function resolvePublishEnvironment(target, environment = process.env, config = easConfig) { + const profile = profiles[target]; + if (!profile) fail(`target must be one of: ${Object.keys(profiles).join(', ')}`); + + const profileEnv = config.build?.[profile.profile]?.env; + if (!profileEnv) fail(`missing EAS build profile ${profile.profile}`); + const env = { ...environment, ...profileEnv, RELEASE_CHANNEL: target }; + + for (const key of requiredPublicKeys) { + if (!env[key]) fail(`${key} is required for ${target} export`); + } + requireCanonicalHttpsRoot(env.EXPO_PUBLIC_SUPABASE_URL, profile.authOrigin, `${target} Auth URL`); + requireCanonicalHttpsRoot(env.EXPO_PUBLIC_API_URL, profile.apiOrigin, `${target} API URL`); + + return { branch: profile.branch, env }; +} + +export function parsePublishArgs(args) { + const parsed = []; + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + const takeValue = (flag, validate) => { + const value = args[index + 1]; + if (!value || !validate(value)) fail(`${flag} requires a valid value`); + parsed.push(flag, value); + index += 1; + }; + if (argument === '--platform') { + takeValue('--platform', (value) => ['ios', 'android', 'all'].includes(value)); + } else if (argument.startsWith('--platform=')) { + const value = argument.slice('--platform='.length); + if (!['ios', 'android', 'all'].includes(value)) fail('--platform requires ios, android, or all'); + parsed.push(argument); + } else if (argument === '-m' || argument === '--message') { + takeValue(argument, (value) => value.length > 0); + } else if (argument.startsWith('--message=')) { + if (!argument.slice('--message='.length)) fail('--message requires a value'); + parsed.push(argument); + } else if (argument === '--rollout-percentage') { + takeValue(argument, (value) => /^(?:[1-9]|[1-9][0-9])$/.test(value)); + } else if (argument.startsWith('--rollout-percentage=')) { + const value = argument.slice('--rollout-percentage='.length); + if (!/^(?:[1-9]|[1-9][0-9])$/.test(value)) fail('--rollout-percentage must be 1-99'); + parsed.push(argument); + } else if (argument === '--nonInteractive') { + parsed.push(argument); + } else { + fail(`unsupported publish argument ${argument}`); + } + } + return parsed; +} + +function run(command, args, env) { + const result = spawnSync(command, args, { cwd: projectDir, env, stdio: 'inherit' }); + if (result.error) throw result.error; + if (result.status !== 0) process.exit(result.status ?? 1); +} + +export function main(args = process.argv.slice(2), environment = process.env) { + const [target, ...remaining] = args; + const preflight = remaining.includes('--preflight'); + const doctor = remaining.includes('--doctor'); + const rawPublishArgs = remaining.filter((arg) => arg !== '--preflight' && arg !== '--doctor'); + if (preflight && doctor) fail('choose either --preflight or --doctor'); + + const { branch, env } = resolvePublishEnvironment(target, environment); + const exportEnv = { ...env, EXPO_NO_DOTENV: '1' }; + if (preflight) { + const outputDir = mkdtempSync(join(tmpdir(), `sauci-ota-${target}-`)); + try { + run('npx', ['expo', 'export', '--output-dir', outputDir], exportEnv); + } finally { + rmSync(outputDir, { recursive: true, force: true }); + } + console.log(`OTA ${target} export preflight passed`); + return; + } + + if (doctor) { + if (rawPublishArgs.length) fail('--doctor does not accept additional EOAS arguments'); + run('npx', ['eoas', 'doctor', '--channel', branch], exportEnv); + return; + } + + run('npx', ['eoas', 'publish', '--branch', branch, ...parsePublishArgs(rawPublishArgs)], exportEnv); +} + +if (process.argv[1] === new URL(import.meta.url).pathname) main(); diff --git a/apps/mobile/scripts/ota-publish.test.mjs b/apps/mobile/scripts/ota-publish.test.mjs new file mode 100644 index 0000000..79df484 --- /dev/null +++ b/apps/mobile/scripts/ota-publish.test.mjs @@ -0,0 +1,109 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { parsePublishArgs, resolvePublishEnvironment } from './ota-publish.mjs'; + +const config = { + build: { + production: { + env: { + EXPO_PUBLIC_API_URL: 'https://api.sauci.app', + EXPO_PUBLIC_SUPABASE_URL: 'https://ckjcrkjpmhqhiucifukx.supabase.co', + EXPO_PUBLIC_SUPABASE_ANON_KEY: 'production-anon-key', + }, + }, + preview: { + env: { + EXPO_PUBLIC_API_URL: 'https://api.preprod.sauci.app', + RELEASE_CHANNEL: 'staging', + }, + }, + }, +}; + +test('production export uses the checked-in production profile', () => { + const { branch, env } = resolvePublishEnvironment('production', {}, config); + + assert.equal(branch, 'production'); + assert.equal(env.RELEASE_CHANNEL, 'production'); + assert.equal(env.EXPO_PUBLIC_API_URL, 'https://api.sauci.app'); +}); + +test('staging export requires the designated non-production Auth configuration', () => { + const environment = { + EXPO_PUBLIC_SUPABASE_URL: 'https://itbzhrvlgvdmzbnhzhyx.supabase.co', + EXPO_PUBLIC_SUPABASE_ANON_KEY: 'staging-anon-key', + }; + const { branch, env } = resolvePublishEnvironment('staging', environment, config); + + assert.equal(branch, 'staging'); + assert.equal(env.RELEASE_CHANNEL, 'staging'); + assert.equal(env.EXPO_PUBLIC_API_URL, 'https://api.preprod.sauci.app'); +}); + +test('staging export rejects production Auth configuration', () => { + assert.throws( + () => resolvePublishEnvironment('staging', { + EXPO_PUBLIC_SUPABASE_URL: 'https://ckjcrkjpmhqhiucifukx.supabase.co', + EXPO_PUBLIC_SUPABASE_ANON_KEY: 'production-anon-key', + }, config), + /canonical HTTPS root/, + ); +}); + +test('rejects malformed API and Auth roots', () => { + for (const invalidAuthUrl of [ + 'http://itbzhrvlgvdmzbnhzhyx.supabase.co', + 'https://user@itbzhrvlgvdmzbnhzhyx.supabase.co', + 'https://itbzhrvlgvdmzbnhzhyx.supabase.co/auth/v1', + 'https://itbzhrvlgvdmzbnhzhyx.supabase.co?source=test', + 'https://itbzhrvlgvdmzbnhzhyx.supabase.co#fragment', + 'https://itbzhrvlgvdmzbnhzhyx.supabase.co:8443', + ]) { + assert.throws( + () => resolvePublishEnvironment('staging', { + EXPO_PUBLIC_SUPABASE_URL: invalidAuthUrl, + EXPO_PUBLIC_SUPABASE_ANON_KEY: 'staging-anon-key', + }, config), + /canonical HTTPS root/, + ); + } + assert.throws( + () => resolvePublishEnvironment('staging', { + EXPO_PUBLIC_SUPABASE_URL: 'https://itbzhrvlgvdmzbnhzhyx.supabase.co', + EXPO_PUBLIC_SUPABASE_ANON_KEY: 'staging-anon-key', + }, { + ...config, + build: { ...config.build, preview: { env: { ...config.build.preview.env, EXPO_PUBLIC_API_URL: 'https://api.preprod.sauci.app/path' } } }, + }), + /canonical HTTPS root/, + ); +}); + +test('permits only documented safe publish arguments', () => { + assert.deepEqual( + parsePublishArgs(['--platform', 'ios', '-m', 'Release notes', '--rollout-percentage', '25', '--nonInteractive']), + ['--platform', 'ios', '-m', 'Release notes', '--rollout-percentage', '25', '--nonInteractive'], + ); + assert.deepEqual( + parsePublishArgs(['--platform=all', '--message=Release', '--rollout-percentage=99']), + ['--platform=all', '--message=Release', '--rollout-percentage=99'], + ); +}); + +test('rejects EOAS target, server, repository, and unknown overrides', () => { + for (const unsafe of [ + '--branch', + '--branch=other', + '--channel', + '--channel=other', + '--serverUrl', + '--serverUrl=https://other.example', + '--server-url', + '--disableRepositoryCheck', + '--disable-repository-check', + '--outputDir', + '--unknown', + ]) { + assert.throws(() => parsePublishArgs([unsafe]), /unsupported publish argument/); + } +}); diff --git a/docs/mobile-ota.md b/docs/mobile-ota.md index ec6ca7d..76ce7c4 100644 --- a/docs/mobile-ota.md +++ b/docs/mobile-ota.md @@ -167,6 +167,9 @@ dashboard and supplied as `EOO_TOKEN`; it is a secret and must not be committed. ```bash cd apps/mobile +# Verify the exact export environment without contacting the update server. +npm run ota:preflight:production + # Staging: consumed by builds made with the EAS `preview` profile. EOO_TOKEN= npm run ota:publish:staging @@ -174,8 +177,29 @@ EOO_TOKEN= npm run ota:publish:staging EOO_TOKEN= npm run ota:publish:production ``` -Both scripts wrap `eoas publish --branch ` with the matching -`RELEASE_CHANNEL`. Useful flags to pass through: +The scripts resolve the matching EAS build profile into EOAS's export subprocess. +EOAS sets `EXPO_NO_DOTENV=1`, so invoking `eoas publish` directly does not load +profile values from `eas.json`. The wrapper injects the selected profile's public +configuration, including the required API and hosted Auth values, sets the +matching `RELEASE_CHANNEL`, and fixes the target branch. It does not print +configuration values. + +Production public API/Auth values come from the checked-in production profile. +Staging uses the preview API value and requires the designated non-production +Auth URL and anonymous key in the invoking environment; it rejects the production +Auth origin. Run its safe export preflight with: + +```bash +EXPO_PUBLIC_SUPABASE_URL=https://itbzhrvlgvdmzbnhzhyx.supabase.co \ +EXPO_PUBLIC_SUPABASE_ANON_KEY= \ +npm run ota:preflight:staging +``` + +`npm run ota:doctor:production` performs EOAS's read-only manifest probes using +the production profile. It does not export or upload an update. Use the staging +doctor command with the same non-production Auth environment as its preflight. + +Useful flags to pass through to a publish command: | Flag | Purpose | | --- | --- |