mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 10:19:43 +00:00
fix(ota): propagate profile env to EOAS
This commit is contained in:
1 parent
a46b8b95e6
commit
549419892d
5 files changed
+289
-5
No files matched your search
@@ -43,6 +43,10 @@ Require all of the following:
|
|||||||
- the configured production API/Auth/RevenueCat environment, without printing
|
- the configured production API/Auth/RevenueCat environment, without printing
|
||||||
secret values.
|
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
|
Do not switch branches, discard work, repair credentials, push, or weaken branch
|
||||||
protection merely to pass preflight. Stop with the exact blocker. Never run
|
protection merely to pass preflight. Stop with the exact blocker. Never run
|
||||||
`expo prebuild` as part of this workflow.
|
`expo prebuild` as part of this workflow.
|
||||||
|
|||||||
@@ -19,10 +19,14 @@
|
|||||||
"release:android": "dotenv -e .env -- eas build --platform android --profile production --local",
|
"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:preview": "dotenv -e .env -- eas build --platform android --profile preview --local",
|
||||||
"release:android:upload": "bundle exec fastlane android upload",
|
"release:android:upload": "bundle exec fastlane android upload",
|
||||||
"ota:publish:staging": "RELEASE_CHANNEL=staging eoas publish --branch staging",
|
"ota:preflight:staging": "node scripts/ota-publish.mjs staging --preflight",
|
||||||
"ota:publish:production": "RELEASE_CHANNEL=production eoas publish --branch production",
|
"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: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": {
|
"dependencies": {
|
||||||
"@bacons/apple-targets": "^0.1.6",
|
"@bacons/apple-targets": "^0.1.6",
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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
@@ -167,6 +167,9 @@ dashboard and supplied as `EOO_TOKEN`; it is a secret and must not be committed.
|
|||||||
```bash
|
```bash
|
||||||
cd apps/mobile
|
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.
|
# Staging: consumed by builds made with the EAS `preview` profile.
|
||||||
EOO_TOKEN=<token> npm run ota:publish:staging
|
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
|
EOO_TOKEN=<token> npm run ota:publish:production
|
||||||
```
|
```
|
||||||
|
|
||||||
Both scripts wrap `eoas publish --branch <branch>` with the matching
|
The scripts resolve the matching EAS build profile into EOAS's export subprocess.
|
||||||
`RELEASE_CHANNEL`. Useful flags to pass through:
|
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 |
|
| Flag | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
|||||||
Reference in new issue
Block a user