Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 107 additions & 3 deletions .github/workflows/kilo-app-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,64 @@ jobs:
validate:
uses: ./.github/workflows/kilo-app-ci.yml

preflight:
needs: [check-changes]
if: needs.check-changes.outputs.should_build == 'true' && github.ref == 'refs/heads/main'
runs-on: ${{ vars.RUNNER_DEFAULT_LABEL || 'ubuntu-latest' }}
timeout-minutes: 15
permissions:
contents: read
steps:
- uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1
with:
lfs: true

- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0

- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: '.nvmrc'
cache: 'pnpm'

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Check EXPO_TOKEN
run: |
if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then
echo "::error::EXPO_TOKEN secret is required for the release preflight"
exit 1
fi
echo "EXPO_TOKEN is present"

- name: Verify EAS production environment
working-directory: apps/mobile
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
run: |
ENV_OUTPUT=$(pnpx eas-cli@21.8.0 env:list --environment production --format long)
MISSING=""
for NAME in API_BASE_URL WEB_BASE_URL CLOUD_AGENT_WS_URL SESSION_INGEST_WS_URL APPSFLYER_DEV_KEY APPSFLYER_APP_ID KILO_CHAT_URL EVENT_SERVICE_URL NOTIFICATIONS_URL POSTHOG_API_KEY SENTRY_AUTH_TOKEN EXPO_PUBLIC_SENTRY_ENVIRONMENT; do
if ! printf '%s\n' "$ENV_OUTPUT" | grep -qE "^Name[[:space:]]+${NAME}[[:space:]]*$"; then
Comment thread
iscekic marked this conversation as resolved.
MISSING="$MISSING $NAME"
fi
done
if [ -n "$MISSING" ]; then
echo "::error::Missing EAS production environment variables:$MISSING"
exit 1
fi
echo "All required EAS production environment variables are present"

- name: Assert production config contract
working-directory: apps/mobile
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
run: EAS_BUILD_PROFILE=production pnpx eas-cli@21.8.0 env:exec production --non-interactive 'pnpm assert:config'

build-and-submit:
needs: [check-changes, validate]
needs: [check-changes, validate, preflight]
if: needs.check-changes.outputs.should_build == 'true' && github.ref == 'refs/heads/main'
runs-on: ${{ vars.RUNNER_DEFAULT_LABEL || 'ubuntu-latest' }}
timeout-minutes: 60
Expand All @@ -74,11 +130,59 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Build and submit
- name: Build iOS and Android
working-directory: apps/mobile
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
run: |
mkdir -p artifacts
pnpx eas-cli@21.8.0 build --profile production --platform all --non-interactive --json --wait > build.json
node ../../scripts/inspect-mobile-artifacts.mjs --select build.json > urls.txt
echo "IOS_URL=$(sed -n '1p' urls.txt)" >> "$GITHUB_ENV"
echo "ANDROID_URL=$(sed -n '2p' urls.txt)" >> "$GITHUB_ENV"

- name: Download artifacts
working-directory: apps/mobile
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
run: |
download() {
url="$1"
out="$2"
label="$3"
if curl -fL -H "Authorization: Bearer $EXPO_TOKEN" -o "$out" "$url"; then
echo "$label: downloaded with Authorization header"
elif curl -fL -o "$out" "$url"; then
echo "$label: downloaded without Authorization header (pre-signed URL rejected the header)"
else
echo "::error::$label download failed"
return 1
fi
}
download "$IOS_URL" artifacts/app.ipa "iOS"
download "$ANDROID_URL" artifacts/app.aab "Android"

- name: Setup Java
uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4.9.1
with:
distribution: 'temurin'
java-version: '17'

- name: Inspect artifacts
working-directory: apps/mobile
run: node ../../scripts/inspect-mobile-artifacts.mjs artifacts/app.ipa artifacts/app.aab build.json

- name: Submit iOS
working-directory: apps/mobile
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
run: pnpx eas-cli@21.8.0 submit --profile production --platform ios --non-interactive --path artifacts/app.ipa

- name: Submit Android
working-directory: apps/mobile
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
run: pnpx eas-cli@latest build --profile production --platform all --auto-submit --non-interactive
run: pnpx eas-cli@21.8.0 submit --profile production --platform android --non-interactive --path artifacts/app.aab

- name: Tag release
run: |
Expand Down
36 changes: 35 additions & 1 deletion apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,50 @@ import type { ExpoConfig } from 'expo/config';
import { ENV_KEYS, OPTIONAL_ENV_KEYS } from './src/lib/env-keys';
import { SENTRY_NATIVE_OPTIONS } from './src/lib/sentry-dsn';
import { UNIVERSAL_LINK_PATH_PATTERNS } from './src/lib/universal-link-paths';
import {
assertProductionHost,
assertUrlScheme,
PRODUCTION_HOSTS,
URL_SCHEMES,
} from './src/lib/url-contract';

const isProductionBuild = process.env.EAS_BUILD_PROFILE === 'production';

// Required env is fatal by build intent: a production build must never ship
// with a missing value, so throw under EAS_BUILD_PROFILE === 'production'.
// Otherwise keep the old behavior: warn under GITHUB_ACTIONS, throw locally.
const missing = Object.values(ENV_KEYS).filter(key => !process.env[key]);
if (missing.length > 0) {
const message = `Missing required environment variables: ${missing.join(', ')}`;
if (process.env.GITHUB_ACTIONS) {
if (isProductionBuild) {
throw new Error(message);
} else if (process.env.GITHUB_ACTIONS) {
console.warn(`⚠️ ${message}`);
} else {
throw new Error(message);
}
}

// URL contract: every URL value must use its allowed scheme. Non-production
// builds additionally permit http:/ws: for local development; production
// builds also assert the host against the production allowlist.
for (const [key, schemes] of Object.entries(URL_SCHEMES)) {
const value = process.env[ENV_KEYS[key as keyof typeof ENV_KEYS]];
if (!value) continue;
assertUrlScheme(key, value, schemes, { allowInsecure: !isProductionBuild });
if (isProductionBuild) {
assertProductionHost(key, value, PRODUCTION_HOSTS);
}
}

// Source-map gate: an unauthenticated production artifact must never reach the
// stores with silently missing symbolication.
if (isProductionBuild && !process.env.SENTRY_AUTH_TOKEN) {
throw new Error(
'Missing SENTRY_AUTH_TOKEN: production builds require an authenticated Sentry source-map upload.'
);
}

// Google OAuth client IDs are public identifiers (committed .env, all EAS
// environments). The conditional below tolerates their absence so the app still builds when a
// checkout lacks them; the native Google button hides itself when undefined.
Expand Down Expand Up @@ -222,6 +255,7 @@ const config: ExpoConfig = {
Object.entries(OPTIONAL_ENV_KEYS).map(([key, env]) => [key, process.env[env]])
),
router: {},
isProductionBuild,
eas: {
projectId: '2cf05e39-90b5-48a5-a8a5-e0b3423cf3f4',
},
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/eas.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"cli": {
"version": ">= 21.1.0",
"version": "21.8.0",
"appVersionSource": "remote"
},
"build": {
Expand Down
13 changes: 7 additions & 6 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@
"start": "expo start --dev-client",
"android": "expo run:android",
"ios": "expo run:ios",
"build": "pnpx eas-cli@latest build --profile development",
"build:ios": "pnpx eas-cli@latest build -p ios --profile development",
"build:android": "pnpx eas-cli@latest build -p android --profile development",
"release:internal": "pnpx eas-cli@latest build --profile production --auto-submit",
"release:internal:ios": "pnpx eas-cli@latest build -p ios --profile production --auto-submit",
"release:internal:android": "pnpx eas-cli@latest build -p android --profile production --auto-submit",
"build": "pnpx eas-cli@21.8.0 build --profile development",
"build:ios": "pnpx eas-cli@21.8.0 build -p ios --profile development",
"build:android": "pnpx eas-cli@21.8.0 build -p android --profile development",
"release:internal": "pnpx eas-cli@21.8.0 build --profile production --auto-submit",
"release:internal:ios": "pnpx eas-cli@21.8.0 build -p ios --profile production --auto-submit",
"release:internal:android": "pnpx eas-cli@21.8.0 build -p android --profile production --auto-submit",
"assert:config": "node scripts/assert-expo-config.mjs",
"typecheck": "tsgo --noEmit",
"lint": "pnpm -w exec oxlint --config apps/mobile/.oxlintrc.json apps/mobile/src",
"format": "oxfmt src",
Expand Down
96 changes: 96 additions & 0 deletions apps/mobile/scripts/assert-expo-config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { execFileSync } from 'node:child_process';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

import { ENV_KEYS } from '../src/lib/env-keys.js';

// Contract values mirrored from app.config.ts (bundle id, package, scheme,
// associated domain, blocked permissions, and Sentry plugin). ENV_KEYS is
// imported live from src/lib/env-keys.js. The script runs the full evaluated
// config, so these must match the resolved build-time output, not the raw
// app.config.ts source.
const BUNDLE_IDENTIFIER = 'com.kilocode.kiloapp';
const ANDROID_PACKAGE = 'com.kilocode.kiloapp';
const SCHEME = 'kiloapp';
const ASSOCIATED_DOMAIN = 'applinks:app.kilo.ai';
const BLOCKED_PERMISSIONS = [
'android.permission.READ_MEDIA_IMAGES',
'android.permission.READ_MEDIA_VIDEO',
'android.permission.READ_MEDIA_AUDIO',
];
const SENTRY_PLUGIN = '@sentry/react-native/expo';

const mobileDir = join(dirname(fileURLToPath(import.meta.url)), '..');

let raw;
try {
raw = execFileSync('npx', ['expo', 'config', '--json'], {
cwd: mobileDir,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'inherit'],
});
} catch (error) {
console.error(`Failed to run "npx expo config --json" from ${mobileDir}: ${error.message}`);
process.exit(1);
}

let config;
try {
config = JSON.parse(raw);
} catch (error) {
console.error(`"npx expo config --json" returned invalid JSON: ${error.message}`);
process.exit(1);
}

const failures = [];

function check(condition, message) {
if (!condition) {
failures.push(message);
}
}

check(
config.ios?.bundleIdentifier === BUNDLE_IDENTIFIER,
`ios.bundleIdentifier must be "${BUNDLE_IDENTIFIER}"`
);
check(config.android?.package === ANDROID_PACKAGE, `android.package must be "${ANDROID_PACKAGE}"`);
check(config.scheme === SCHEME, `scheme must be "${SCHEME}"`);

const associatedDomains = config.ios?.associatedDomains ?? [];
check(
associatedDomains.includes(ASSOCIATED_DOMAIN),
`ios.associatedDomains must contain "${ASSOCIATED_DOMAIN}"`
);

const blockedPermissions = config.android?.blockedPermissions ?? [];
const blockedPermissionsMatch =
blockedPermissions.length === BLOCKED_PERMISSIONS.length &&
BLOCKED_PERMISSIONS.every(permission => blockedPermissions.includes(permission));
check(
blockedPermissionsMatch,
`android.blockedPermissions must equal exactly [${BLOCKED_PERMISSIONS.join(', ')}]`
);

const pluginNames = (config.plugins ?? []).map(plugin =>
Array.isArray(plugin) ? plugin[0] : plugin
);
check(pluginNames.includes(SENTRY_PLUGIN), `plugins must include "${SENTRY_PLUGIN}"`);

const extra = config.extra ?? {};
for (const key of Object.keys(ENV_KEYS)) {
const value = extra[key];
if (value === undefined || value === null || value === '') {
failures.push(`extra.${key} must be present and non-empty`);
}
}

if (failures.length > 0) {
console.error('Expo config contract violations:');
for (const failure of failures) {
console.error(` - ${failure}`);
}
process.exit(1);
}

console.log('Expo config contract OK');
Loading