Skip to content
Merged
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
46 changes: 44 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,21 @@ The arguments after the override command are the flags you want to `+` enable or

You can run `yarn build-flags ota-override` instead of "override" to do the same but also consider the branch name in two supported CI environments: Github and Gitlab. Use the `ota.branches` array in the flags.yml to setup that matching and branch-based enablement.

Use `--skip-if-env <ENV_VAR_NAME>` passing the name of the environment variable to check. If the variable has a truthy value the CLI command will be a no-op/skipped. This can be useful in CI contexts like EAS where other processes may handle generating the runtime build flags. In the case of EAS you could use `--skip-if-env EAS_BUILD`
### Resolution model

Both the CLI and the config plugin resolve flag values through one canonical
function with a fixed precedence (later steps win):

1. defaults (the `value` in flags.yml)
2. branch enablement (`ota.branches`, only with `ota-override` / in CI)
3. inversions (`invertFor.bundleId` / `invertFor.platform`)
4. explicit enable (`+flag` / `EXPO_BUILD_FLAGS`)
5. explicit disable (`-flag` / `EXPO_BUILD_FLAGS`) — disable always wins

When a flag's `invertFor` uses `bundleId`, the CLI resolves your project's Expo
config (via `expo config --json`) so it sees the same — possibly dynamically
derived — bundle identifier the config plugin sees at prebuild. This subprocess
only runs when the spec actually contains a `bundleId` matcher.

### Set Flags in CI & for Static Builds

Expand Down Expand Up @@ -97,14 +111,42 @@ flags:
- com.example.app.special
```

With the preceding config and the expo config-plugin installed, the `featureOnForSpecificBundleId` flag is true for native builds that have the matching bundleId. This inversion only applies during a new native build with expo prebuild. To invert the flag during development you should still use the command line tooling.
With the preceding config and the expo config-plugin installed, the `featureOnForSpecificBundleId` flag is true for native builds that have the matching bundleId.

You can also invert based on the platform being built:

```yaml
flags:
iosOnlyFeature:
value: false
invertFor:
platform:
- ios
```

If both `bundleId` and `platform` are specified, the inversion fires when
**either** matches.

When a `platform` matcher causes a flag to resolve differently across iOS and
Android, the runtime module is emitted per platform as `<mergePath>.ios.ts` and
`<mergePath>.android.ts` instead of the single `mergePath` file (the stale form
is cleaned up automatically). The babel plugin selects the correct file for the
platform being compiled; if you import the module directly at runtime, Metro's
platform resolution picks it up. When no flag diverges across platforms, a
single shared module is written as before.

Unlike before, both the CLI and the config plugin apply `invertFor` — the CLI
resolves the Expo config when a `bundleId` matcher is present so its output
agrees with the plugin's.

## Goals

- [x] allow defining a base set of flags that are available at runtime in one place
- [x] allow for overriding a flag's value locally during development (without having to change the default value committed to source control)
- [x] allow for running OTA updates with the flag on for specific CI branches
- [x] allow for overriding a flag's value for any native build for one-off testing
- [x] allow flag values to resolve per-platform (iOS vs Android) via `invertFor.platform`
- [x] unify CLI and config-plugin resolution so their core flag output always agrees
- [x] allow for referencing flag values in JS
- [ ] allow for referencing flag values from native code on iOS or Android
- [x] allow for tree-shaking of the JS bundle and dead code path elimination
Expand Down
85 changes: 0 additions & 85 deletions src/api/BuildFlags.ts

This file was deleted.

76 changes: 76 additions & 0 deletions src/api/agreement.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { jest, describe, it, expect, beforeEach } from "@jest/globals";
import * as fs from "fs/promises";
import { resolve, enabledNames } from "./resolve";
import { resolveEnabledFlagNames, resolveFlags } from "./generateOverrides";
import { FlagMap } from "./types";

jest.mock("fs/promises", () => ({
readFile: jest.fn(),
}));

// A spec exercising every input axis: defaults, bundleId + platform inversion.
const specYaml = `
mergePath: "constants/buildFlags.ts"
flags:
base:
value: true
bundleScoped:
value: false
invertFor:
bundleId:
- com.my.app.apple
iosOnly:
value: false
invertFor:
platform:
- ios
`;

const parseSpec = (): FlagMap => {
const YAML = require("yaml");
return YAML.parse(specYaml).flags;
};

const expoConfig = {
ios: { bundleIdentifier: "com.my.app.apple" },
android: { package: "com.my.app" },
} as any;

describe("CLI <-> config-plugin resolution agreement", () => {
beforeEach(() => {
jest.spyOn(fs, "readFile").mockImplementation((path: any) => {
if (path.endsWith("flags.yml")) {
return Promise.resolve(specYaml) as any;
}
throw new Error(`readFile: path not mocked: ${path}`);
});
});

it("plugin native path agrees with the resolved FlagMap per platform", async () => {
for (const platform of ["ios", "android"] as const) {
// Plugin native side-effect path (manifest/plist) uses resolveEnabledFlagNames.
const nativeNames = await resolveEnabledFlagNames({
expoConfig,
platform,
});
// CLI/bundle path resolves the same FlagMap and the babel/runtime read it.
const bundleMap = await resolveFlags({ expoConfig, platform });

expect(nativeNames.sort()).toEqual(enabledNames(bundleMap).sort());
}
});

it("produces the documented per-platform values for this spec", async () => {
const ios = resolve(parseSpec(), { expoConfig, platform: "ios" });
const android = resolve(parseSpec(), { expoConfig, platform: "android" });

// base default-on everywhere; bundleScoped inverted by matching apple id on
// both platform passes (bundleId match is platform-agnostic); iosOnly only on ios.
expect(enabledNames(ios).sort()).toEqual(
["base", "bundleScoped", "iosOnly"].sort()
);
expect(enabledNames(android).sort()).toEqual(
["base", "bundleScoped"].sort()
);
});
});
40 changes: 40 additions & 0 deletions src/api/expoConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { ExpoConfig } from "@expo/config-types";
import { execFile } from "child_process";
import { promisify } from "util";
import { debug } from "./debug";

const execFileAsync = promisify(execFile);

let cached: ExpoConfig | null | undefined;

/**
* Resolve the project's Expo config by shelling out to `expo config --json`.
* This evaluates dynamic config (app.config.js/ts) under the current env, so
* the CLI sees the same resolved bundle identifier the config plugin would at
* prebuild time. Result is cached for the lifetime of the process.
*
* Returns null if the Expo CLI is unavailable or the config can't be read; the
* caller should treat that as "no config-dependent inversions resolvable".
*/
export const readExpoConfig = async (): Promise<ExpoConfig | null> => {
if (cached !== undefined) {
return cached;
}
try {
const { stdout } = await execFileAsync(
"npx",
["expo", "config", "--json"],
{ maxBuffer: 1024 * 1024 * 16 }
);
cached = JSON.parse(stdout) as ExpoConfig;
debug(
"resolved expo config: ios.bundleIdentifier=%o android.package=%o",
cached.ios?.bundleIdentifier,
cached.android?.package
);
} catch (e) {
debug("failed to resolve expo config via `expo config --json`: %o", e);
cached = null;
}
return cached;
};
12 changes: 12 additions & 0 deletions src/api/fixtures/flags.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,15 @@ flags:
bundleId:
- com.my.app.apple
- com.my.app.android
platformInvertableFeature:
value: false
invertFor:
platform:
- ios
combinedInvertableFeature:
value: false
invertFor:
bundleId:
- com.my.app.special
platform:
- android
96 changes: 58 additions & 38 deletions src/api/generateOverrides.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,65 @@
import { BuildFlags } from "./BuildFlags";
import type { ExpoConfig } from "@expo/config-types";
import { readConfig } from "./readConfig";
import { getCIBranch } from "./ciHelpers";
import { resolve, enabledNames, ResolveContext } from "./resolve";
import { saveFlags, savePlatformFlags } from "./writeFlags";
import { FlagMap, Platform } from "./types";

export const resolveEnabledFlagNames = async ({
flagsToEnable,
flagsToDisable,
}: {
type OverrideOptions = {
flagsToEnable?: Set<string>;
flagsToDisable?: Set<string>;
}): Promise<string[]> => {
const { flags: defaultFlags } = await readConfig();
const flags = new BuildFlags(defaultFlags);
if (flagsToEnable) {
flags.enable(flagsToEnable);
}
if (flagsToDisable) {
flags.disable(flagsToDisable);
}
return Object.entries(flags.flags)
.filter(([_, config]) => config.value)
.map(([name]) => name);
enableBranchFlags?: boolean;
expoConfig?: ExpoConfig;
};

export const generateOverrides = async ({
flagsToEnable,
flagsToDisable,
enableBranchFlags,
}: {
flagsToEnable?: Set<string>;
flagsToDisable?: Set<string>;
enableBranchFlags?: boolean;
}) => {
const { mergePath, flags: defaultFlags } = await readConfig();
const flags = new BuildFlags(defaultFlags);
if (enableBranchFlags) {
flags.enableBranchFlags();
}
if (flagsToEnable) {
flags.enable(flagsToEnable);
}
if (flagsToDisable) {
flags.disable(flagsToDisable);
}
await flags.save(mergePath);
const baseContext = (
opts: OverrideOptions
): Omit<ResolveContext, "platform"> => ({
expoConfig: opts.expoConfig,
branch: opts.enableBranchFlags ? getCIBranch() : undefined,
enableBranchFlags: opts.enableBranchFlags,
envEnable: opts.flagsToEnable,
envDisable: opts.flagsToDisable,
});

/**
* Resolve the runtime flags from flags.yml and write the runtime module(s).
* Writes platform-specific files (.ios.ts / .android.ts) only when a flag
* resolves differently across platforms; otherwise a single shared module.
* Called by the CLI and the config plugin so both produce identical output.
*/
export const generateOverrides = async (opts: OverrideOptions = {}) => {
const { mergePath, flags: spec } = await readConfig();
const ctx = baseContext(opts);

const ios = resolve(spec, { ...ctx, platform: "ios" });
const android = resolve(spec, { ...ctx, platform: "android" });

await savePlatformFlags(mergePath, ios, android);
};

/** Resolve and write without a platform axis (used where platform is irrelevant). */
export const generateSharedOverrides = async (opts: OverrideOptions = {}) => {
const { mergePath, flags: spec } = await readConfig();
const resolved = resolve(spec, baseContext(opts));
await saveFlags(mergePath, resolved);
};

/** Names of flags that resolve enabled for a given platform/context. */
export const resolveEnabledFlagNames = async ({
platform,
...opts
}: OverrideOptions & { platform?: Platform } = {}): Promise<string[]> => {
const { flags: spec } = await readConfig();
const resolved = resolve(spec, { ...baseContext(opts), platform });
return enabledNames(resolved);
};

/** Resolve the full FlagMap for a platform (used by autolinking exclusions). */
export const resolveFlags = async ({
platform,
...opts
}: OverrideOptions & { platform?: Platform } = {}): Promise<FlagMap> => {
const { flags: spec } = await readConfig();
return resolve(spec, { ...baseContext(opts), platform });
};
Loading
Loading