.github/
├── builds.yml # WHAT to build (configuration data)
├── builds.README.md # This documentation
└── workflows/
└── build.yml # HOW to build (CI/CD automation)
scripts/
├── apply-build-config.js # Runtime: reads builds.yml → exports env, code_fencing, remote_feature_flags
├── validate-build-config.js # Validates builds.yml structure
├── set-secrets-from-config.js # Runtime: maps GitHub Secrets → env vars (writes to GITHUB_ENV)
└── generate-build-workflow-secrets-env.js # Maintenance: regenerates build.yml "Set secrets" env from builds.yml
| Script | When it runs | Responsibility |
|---|---|---|
| apply-build-config.js | At workflow runtime (during "Apply build config" step) | Exports non-secret config from builds.yml: env, code_fencing, remote_feature_flags. Does not handle secrets. |
| set-secrets-from-config.js | At workflow runtime (during "Set secrets" step) | Maps GitHub Environment secrets to env vars and writes them to GITHUB_ENV so subsequent steps (e.g. build.sh) see them. |
| generate-build-workflow-secrets-env.js | At edit time (on your machine, when you change builds.yml) | Regenerates the "Set secrets" step env: block in build.yml from builds.yml. GitHub Actions requires each secret to be explicitly listed in the workflow YAML; this script keeps that list in sync with builds.yml so you don't maintain it by hand. |
Why two scripts for builds.yml? apply-build-config.js runs during the workflow and exports config. Secrets cannot be injected dynamically at runtime—the workflow file must list each secrets.SECRET_NAME explicitly. So a separate maintenance script (generate-build-workflow-secrets-env.js) reads builds.yml and generates that list into build.yml. Single source of truth: builds.yml.
Purpose: Single source of truth for all build variants.
Contains:
- Environment variables (API URLs, build config)
- Secret mappings (which GitHub Secret to use for each env var)
- Code fencing features (what code to include/exclude)
- Remote feature flag defaults (build-time defaults for LaunchDarkly flags)
Example entry:
builds:
main-prod:
github_environment: build-production
env:
METAMASK_ENVIRONMENT: 'production'
METAMASK_BUILD_TYPE: 'main'
PORTFOLIO_API_URL: 'https://portfolio.api.cx.metamask.io'
secrets:
SEGMENT_WRITE_KEY: 'SEGMENT_WRITE_KEY'
MM_SENTRY_DSN: 'MM_SENTRY_DSN'
code_fencing:
- preinstalled-snaps
- keyring-snaps
remote_feature_flags:
perpsPerpTradingEnabled: false
earnPooledStakingEnabled: true
earnStablecoinLendingEnabled: truePurpose: GitHub Actions workflow that orchestrates the build process.
Responsibilities:
- Receive build request (manual trigger or called by another workflow)
- Load configuration from
builds.yml - Handle approval gates for production builds
- Execute the build on appropriate runners
Flow:
┌─────────────────────────────────────────────────────────────────┐
│ workflow_dispatch / workflow_call │
│ (build_name, platform) │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ JOB: prepare │
│ ├── yarn install │
│ ├── validate-build-config.js (fail fast if invalid) │
│ └── Load config from builds.yml │
│ └── Output: requires_approval, github_environment, secrets │
└─────────────────────────────────────────────────────────────────┘
│
┌─────────────┴─────────────┐
│ │
requires_approval=true requires_approval=false
│ │
▼ │
┌───────────────────────────────┐ │
│ JOB: approval │ │
│ └── Wait for manual approval │ │
└───────────────────────────────┘ │
│ │
└─────────────┬─────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ JOB: build (matrix: android/ios) │
│ ├── yarn install │
│ ├── apply-build-config.js --export (set env vars) │
│ ├── set-secrets-from-config.js (map secrets) │
│ └── ./scripts/build.sh │
└─────────────────────────────────────────────────────────────────┘
Maintenance: The "Set secrets" step env block in build.yml is generated from builds.yml. When you add, remove, or rename entries in any build's secrets map, run yarn build:workflow:update-secrets to regenerate that block (single source of truth: builds.yml).
Purpose: Reads builds.yml and exports non-secret config: env vars, code fencing, remote feature flag defaults. Does not handle secrets (those are injected by the "Set secrets" step).
Two modes:
# Mode 1: Direct (sets process.env in Node.js)
node scripts/apply-build-config.js main-prod
# Mode 2: Shell export (outputs eval-able statements)
eval "$(node scripts/apply-build-config.js main-prod --export)"What it does:
builds.yml Environment Variables
───────────────────────────────────────────────────────────────────────────
env: → METAMASK_ENVIRONMENT=production
METAMASK_ENVIRONMENT METAMASK_BUILD_TYPE=main
METAMASK_BUILD_TYPE PORTFOLIO_API_URL=https://portfolio.api.cx.metamask.io
PORTFOLIO_API_URL ...
code_fencing: → CODE_FENCING_FEATURES=["preinstalled-snaps",...]
- preinstalled-snaps
- keyring-snaps
remote_feature_flags: → REMOTE_FEATURE_FLAG_DEFAULTS={"perpsPerpTradingEnabled":false,...}
perpsPerpTradingEnabled: false
earnPooledStakingEnabled: true
Server URL Strategy:
_serversanchor: Production URLs as defaults- Test/e2e/exp/dev builds override with UAT/dev URLs
- GitHub Environment determines actual secret values (same secret names, different values per environment)
Purpose: Validates builds.yml structure before builds run.
Checks:
- YAML syntax is valid
- All builds have required fields:
env.METAMASK_ENVIRONMENTenv.METAMASK_BUILD_TYPEgithub_environment
Usage:
node scripts/validate-build-config.js
# ✅ Valid: 8 builds configured
# main-prod, main-rc, main-dev, main-test, flask-prod, flask-rc, flask-dev, flask-testPurpose: Maps GitHub Secrets to environment variables based on config.
How it works:
builds.yml secrets: GitHub Secrets App expects
─────────────────────────────────────────────────────────────────────────────
SEGMENT_WRITE_KEY: "SEGMENT_KEY_QA" → $SEGMENT_KEY_QA → $SEGMENT_WRITE_KEY
MM_SENTRY_DSN: "MM_SENTRY_DSN_TEST" → $MM_SENTRY_DSN_TEST → $MM_SENTRY_DSN
Why mapping? Different builds use different secrets, but the app always expects the same env var names.
Usage:
CONFIG_SECRETS='{"SEGMENT_WRITE_KEY":"SEGMENT_KEY_QA"}' node scripts/set-secrets-from-config.js
# ✓ SEGMENT_WRITE_KEYPurpose: Regenerates the "Set secrets" step env: block in .github/workflows/build.yml from builds.yml. Runs at edit time (on your machine), not during workflow execution.
Why? GitHub Actions requires each secret to be explicitly listed in the workflow YAML (e.g. SECRET_NAME: ${{ secrets.SECRET_NAME }}). It cannot dynamically inject secrets from a list. This script keeps that list in sync with builds.yml so you don't maintain it by hand.
When to run: After adding, removing, or renaming entries in any build's secrets map in builds.yml.
Usage:
yarn build:workflow:update-secrets
# Found 45 unique secret names from builds.yml
# Updated .github/workflows/build.yml┌─────────────────────────────────────────────────────────────────────────────┐
│ BUILD TIME │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ .github/builds.yml │
│ │ │
│ ├────────────────┬────────────────┬────────────────┐ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────────────┐ │
│ │ env │ │ secrets │ │ code_ │ │ remote_feature_ │ │
│ │ │ │ │ │ fencing │ │ flags │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └───────┬─────────┘ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ apply-build- set-secrets- metro.transform.js apply-build- │
│ config.js from-config.js (removes code) config.js │
│ │ │ │ │ │
│ └───────┬───────┘ │ │ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ Environment Vars Bundled JavaScript REMOTE_FEATURE_ │
│ │ │ FLAG_DEFAULTS │
│ │ │ │ │
│ └───────────┬───────────┴──────────────────┘ │
│ │ │
│ ▼ │
│ Built App (.apk / .ipa) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ RUNTIME │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ RemoteFeatureFlagController │
│ ├── Build-time defaults seeded (from REMOTE_FEATURE_FLAG_DEFAULTS) │
│ │ │
│ ├── LaunchDarkly fetches and OVERRIDES at runtime │
│ │ │
│ └── Selectors read from remoteFeatureFlags │
│ │ │
│ ▼ │
│ App behavior (feature enabled/disabled) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
When the same variable is set in multiple places:
1. .js.env (local dev only) ← Highest priority
2. LaunchDarkly (runtime) ← Overrides remote feature flags
3. builds.yml (build time) ← Default values
Example for env vars:
# builds.yml sets:
RAMPS_ENVIRONMENT: "production"
# Developer's .js.env overrides for local testing:
export RAMPS_ENVIRONMENT="staging"
# Result: App uses "staging" locally, "production" in CI buildsbuilds.yml is the single source of truth for remote feature flag defaults. These are seeded into RemoteFeatureFlagController at startup, then LaunchDarkly can override them at runtime.
Following the same pattern as _servers and _secrets:
# Single anchor with production (conservative) defaults
_remote_feature_flags: &remote_feature_flags
perpsPerpTradingEnabled: false
earnPooledStakingEnabled: true
earnMusdConversionFlowEnabled: false
builds:
# Prod/RC/Test/E2E use defaults directly
main-prod:
remote_feature_flags: *remote_feature_flags
# Dev/Exp override specific flags
main-dev:
remote_feature_flags:
<<: *remote_feature_flags
perpsPerpTradingEnabled: true # Enable for testing
earnMusdConversionFlowEnabled: true┌─────────────────────────────────────────────────────────────────────────────┐
│ builds.yml │
│ ├── _remote_feature_flags (anchor with prod defaults) │
│ ├── main-prod uses *remote_feature_flags directly │
│ └── main-dev uses <<: *remote_feature_flags + overrides │
│ │
│ ↓ (build time) │
│ │
│ REMOTE_FEATURE_FLAG_DEFAULTS env var (JSON) │
│ │
│ ↓ (app startup) │
│ │
│ RemoteFeatureFlagController seeds defaults │
│ │
│ ↓ (runtime) │
│ │
│ LaunchDarkly fetches and OVERRIDES (versioned flags with minAppVersion) │
│ │
│ ↓ │
│ │
│ Selectors read from remoteFeatureFlags (single source) │
└─────────────────────────────────────────────────────────────────────────────┘
Selectors use this pattern to read feature flags:
export const selectPerpsEnabledFlag = createSelector(
selectRemoteFeatureFlags,
(remoteFeatureFlags) => {
const remoteFlag =
remoteFeatureFlags?.perpsPerpTradingEnabled as VersionGatedFeatureFlag;
// Try versioned flag first (from LaunchDarkly), fall back to build-time default
return (
validatedVersionGatedFeatureFlag(remoteFlag) ??
(remoteFeatureFlags?.perpsPerpTradingEnabled as boolean)
);
},
);Flow:
validatedVersionGatedFeatureFlag()checks if LaunchDarkly returned a versioned flag with version gating- If not (returns
undefined), falls back to the build-time default (simple boolean frombuilds.yml)
- Single source of truth - One anchor defines all production defaults
- Explicit overrides - Dev builds show exactly which flags differ from production
- No hardcoded fallbacks - Selectors trust the seeded defaults
- Easy maintenance - Adding a new flag only requires updating one anchor
- LaunchDarkly still works - Runtime overrides with version gating
| Build | Environment | GitHub Environment | Use Case |
|---|---|---|---|
main-prod |
production | build-production | App Store release |
main-rc |
rc | build-rc | Release candidate testing |
main-test |
test | build-test | QA testing |
main-e2e |
e2e | build-e2e | E2E automated tests |
main-exp |
exp | build-exp | Experimental builds |
main-dev |
dev | build-dev | Local development |
flask-prod |
production | build-production | Flask App Store release |
flask-test |
test | build-test | Flask QA testing |
flask-e2e |
e2e | build-e2e | Flask E2E tests |
flask-dev |
dev | build-dev | Flask local development |
Builds that produce signed device binaries (not simulator/debug) use AWS Secrets Manager for code signing. The signing section in builds.yml maps each build to an AWS role and secret.
| Build type | AWS Role | AWS Secret | Android keystore |
|---|---|---|---|
| main-prod | metamask-mobile-prod-signer | metamask-mobile-main-prod-signer | release.keystore |
| main-rc | metamask-mobile-rc-signer | metamask-mobile-main-rc-signer | rc.keystore |
| main-test, main-e2e, main-exp, qa-prod | metamask-mobile-uat-signer | metamask-mobile-main-uat-signer | internalRelease.keystore |
| flask-prod | metamask-mobile-prod-signer | metamask-mobile-flask-prod-signer | flaskRelease.keystore |
| flask-test, flask-e2e | metamask-mobile-uat-signer | metamask-mobile-flask-uat-signer | flask-uat.keystore |
Dev builds (main-dev, flask-dev, qa-dev) omit signing — they use simulator/debug and require no certificates.
AWS secret structure (per build.gradle expectations): The secret must contain keys such as ANDROID_KEYSTORE (base64), BITRISEIO_ANDROID_QA_KEYSTORE_PASSWORD, BITRISEIO_ANDROID_QA_KEYSTORE_ALIAS, BITRISEIO_ANDROID_QA_KEYSTORE_PRIVATE_KEY_PASSWORD for UAT builds; adjust for prod/rc/flask variants.
builds.yml uses YAML anchors to avoid repetition. The pattern is: single anchor with production defaults, override in specific builds as needed.
# Define anchors with production (conservative) defaults
_servers: &servers
PORTFOLIO_API_URL: 'https://portfolio.api.cx.metamask.io'
SECURITY_ALERTS_API_URL: 'https://security-alerts.api.cx.metamask.io'
_remote_feature_flags: &remote_feature_flags
perpsPerpTradingEnabled: false
earnPooledStakingEnabled: true
earnMusdConversionFlowEnabled: false
# Reuse via aliases, override as needed
builds:
main-prod:
env:
<<: *servers
remote_feature_flags: *remote_feature_flags # Use defaults directly
main-dev:
env:
<<: *servers
PORTFOLIO_API_URL: 'https://portfolio.dev-api.cx.metamask.io' # Override URL
remote_feature_flags:
<<: *remote_feature_flags
# Override specific flags for dev testing
perpsPerpTradingEnabled: true
earnMusdConversionFlowEnabled: trueKey: Anchors are resolved at YAML parse time, not runtime. No magic inheritance logic needed.
Pattern Benefits:
- Single source of truth for defaults
- Explicit overrides show exactly what differs from production
- Adding a new flag only requires updating one anchor
For feature flags that LaunchDarkly may control, add to remote_feature_flags:
- Add to
.github/builds.ymlanchor (production default):
# Add to the single anchor with production (conservative) default
_remote_feature_flags: &remote_feature_flags # ... existing flags ...
myNewFeatureEnabled: false # Conservative for prod- Override in dev/exp builds if needed:
builds:
main-dev:
remote_feature_flags:
<<: *remote_feature_flags
# Override for dev testing
myNewFeatureEnabled: true- Create selector in your feature's selectors:
export const selectMyNewFeatureEnabled = createSelector(
selectRemoteFeatureFlags,
(remoteFeatureFlags) => {
const remoteFlag =
remoteFeatureFlags?.myNewFeatureEnabled as VersionGatedFeatureFlag;
return (
validatedVersionGatedFeatureFlag(remoteFlag) ??
(remoteFeatureFlags?.myNewFeatureEnabled as boolean)
);
},
);- Use in component:
const isEnabled = useSelector(selectMyNewFeatureEnabled);For non-feature-flag config (API URLs, build settings), add to env:
- Add to
.github/builds.yml:
builds:
main-prod:
env:
MY_API_URL: 'https://api.example.com'
main-dev:
env:
MY_API_URL: 'https://dev-api.example.com'- Document in
.js.env.example:
export MY_API_URL="https://api.example.com"- Use in code:
const apiUrl = process.env.MY_API_URL;-
Add to GitHub repository secrets
-
Add mapping in
.github/builds.yml:
builds:
main-prod:
secrets:
MY_API_KEY: 'MY_API_KEY_PROD'
main-dev:
secrets:
MY_API_KEY: 'MY_API_KEY_DEV'- Add to
.github/builds.yml:
builds:
main-beta:
requires_approval: true
github_environment: build-beta
env:
METAMASK_ENVIRONMENT: 'beta'
METAMASK_BUILD_TYPE: 'main'
# ... rest of config- Add to workflow dropdown in
.github/workflows/build.yml:
build_name:
type: choice
options:
- main-prod
- main-beta # Add here
- main-rc❌ Build "main-foo" not found. Available: main-prod, main-rc, ...
→ Check build name matches exactly in .github/builds.yml
❌ main-prod: missing env.METAMASK_ENVIRONMENT
→ Ensure all required fields are present
- Check builds.yml - Is the flag defined in
remote_feature_flags? - Check LaunchDarkly - Is LaunchDarkly overriding with a different value?
- Check selector - Does it follow the pattern?
validatedVersionGatedFeatureFlag(remoteFlag) ?? (remoteFeatureFlags?.flagName as boolean);
- Check flag name - LaunchDarkly flag name must match exactly (e.g.,
perpsPerpTradingEnabled)
⚠ SEGMENT_KEY_PROD not found (for SEGMENT_WRITE_KEY)
→ Ensure GitHub Secret exists with exact name from secrets mapping
# Validate config
node scripts/validate-build-config.js
# See what env vars would be set (including remote feature flag defaults)
node scripts/apply-build-config.js main-dev --export
# Apply config and verify
eval "$(node scripts/apply-build-config.js main-dev --export)"
echo $METAMASK_ENVIRONMENT # Should print: dev
echo $REMOTE_FEATURE_FLAG_DEFAULTS # Should print: {"perpsPerpTradingEnabled":true,...}
# Parse remote feature flags JSON
node -e "console.log(JSON.parse(process.env.REMOTE_FEATURE_FLAG_DEFAULTS))"