fix(ota): propagate profile env to EOAS

This commit is contained in:
jjbrunton committed 2026-08-30 22:07:38 +01:00
1 parent a46b8b95e6
commit 549419892d
5 files changed
+289 -5

No files matched your search

+4
View File
@@ -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.
+7 -3
View File
@@ -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",
+143
View File
@@ -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();
+109
View File
@@ -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/);
}
});
+26 -2
View File
@@ -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=<token> npm run ota:publish:staging
@@ -174,8 +177,29 @@ EOO_TOKEN=<token> npm run ota:publish:staging
EOO_TOKEN=<token> npm run ota:publish:production
```
Both scripts wrap `eoas publish --branch <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=<non-production-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 |
| --- | --- |