Skip to content

Commit 552c7be

Browse files
gewenyu99claude
andcommitted
feat(ci): flag overrides that exist only in CI builds
POSTHOG_WIZARD_CI_FLAG_OVERRIDES (a JSON object of flag key → value) merges over the flags PostHog returns, so CI routes deterministically — a run that tests the orchestrator arm says so instead of depending on a live flag. Published builds inline NODE_ENV and tsdown strips the entire override path from the bundle; the smoke test asserts the env var's name is physically absent from production output and present in CI output, on every build. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0d66b57 commit 552c7be

5 files changed

Lines changed: 157 additions & 6 deletions

File tree

scripts/smoke-test.sh

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,38 @@ node --input-type=module -e "import '$DIST_BIN'" 2>&1 | head -5 | grep -q 'PostH
1919
exit 1
2020
}
2121

22-
# ── 2. --ci rejected in production builds ────────────────────────────────────
22+
# ── 2. CI flag overrides physically absent from production builds ───────────
23+
# The override path (src/utils/ci-flag-overrides.ts) is dead code in published
24+
# builds and tsdown strips it; its env var name appearing in dist/*.js means
25+
# dead-code elimination regressed and a prod surface leaked. Sourcemaps keep
26+
# the original source, so only .js output counts.
27+
OVERRIDE_MARKER='WIZARD_CI_FLAG_OVERRIDES'
28+
if [ "${WIZARD_BUILD_NODE_ENV:-production}" = "ci" ]; then
29+
# CI builds must keep the path — its absence means the override silently
30+
# stopped working and CI is back to testing live flags.
31+
if ! grep -q "$OVERRIDE_MARKER" ./dist/*.js; then
32+
echo 'Smoke test failed: CI build is missing the CI flag-override path' >&2
33+
exit 1
34+
fi
35+
# And a real invocation must accept the env var. yargs claims every
36+
# POSTHOG_WIZARD_-prefixed env var as a CLI option and strict-rejects
37+
# unknown ones during command parse (--version/--help short-circuit and
38+
# prove nothing). The run exits fast on the missing api key — all this
39+
# asserts is that yargs did not reject the environment.
40+
ci_probe=$(WIZARD_CI_FLAG_OVERRIDES='{"wizard-orchestrator":true}' node "$DIST_BIN" --ci --install-dir /tmp/wizard-smoke-probe 2>&1) || true
41+
if echo "$ci_probe" | grep -q 'Unknown argument'; then
42+
echo 'Smoke test failed: CI binary rejects WIZARD_CI_FLAG_OVERRIDES in the environment' >&2
43+
echo "$ci_probe" | head -3 >&2
44+
exit 1
45+
fi
46+
else
47+
if grep -q "$OVERRIDE_MARKER" ./dist/*.js; then
48+
echo 'Smoke test failed: CI flag-override code leaked into a production build' >&2
49+
exit 1
50+
fi
51+
fi
52+
53+
# ── 3. --ci rejected in production builds ────────────────────────────────────
2354
# build:ci sets WIZARD_BUILD_NODE_ENV=ci → --ci stays enabled → skip the check.
2455
if [ "${WIZARD_BUILD_NODE_ENV:-production}" = "ci" ]; then
2556
exit 0

src/env.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ export const IS_PRODUCTION_BUILD = process.env.NODE_ENV === 'production';
3939
* Add new keys here when a new runtime dependency is needed.
4040
*/
4141
type RuntimeEnvKey =
42+
// CI-build-only flag overrides (see utils/ci-flag-overrides.ts).
43+
// Deliberately NOT POSTHOG_WIZARD_-prefixed: yargs .env('POSTHOG_WIZARD')
44+
// would claim it as an unknown CLI option and strict-reject the run.
45+
| 'WIZARD_CI_FLAG_OVERRIDES'
4246
// Wizard CLI configuration (yargs POSTHOG_WIZARD_ prefix)
4347
| 'POSTHOG_WIZARD_BENCHMARK_CONFIG'
4448
| 'POSTHOG_WIZARD_BENCHMARK_FILE'
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { applyCiFlagOverrides } from '@utils/ci-flag-overrides';
2+
3+
jest.mock('@utils/debug', () => ({
4+
logToFile: jest.fn(),
5+
debug: jest.fn(),
6+
}));
7+
8+
const ENV_KEY = 'WIZARD_CI_FLAG_OVERRIDES';
9+
10+
describe('applyCiFlagOverrides', () => {
11+
afterEach(() => {
12+
delete process.env[ENV_KEY];
13+
});
14+
15+
// Jest runs with NODE_ENV=test, so IS_PRODUCTION_BUILD is false and the
16+
// override path is live — the same shape a `build:ci` bundle has.
17+
describe('in CI builds', () => {
18+
it('returns the flags untouched when no override is set', () => {
19+
const flags = { 'wizard-orchestrator': 'false' };
20+
expect(applyCiFlagOverrides(flags)).toEqual(flags);
21+
});
22+
23+
it('merges overrides over the fetched flags, stringifying values', () => {
24+
process.env[ENV_KEY] = JSON.stringify({
25+
'wizard-orchestrator': true,
26+
'wizard-next-v2': 'legacy',
27+
});
28+
expect(
29+
applyCiFlagOverrides({
30+
'wizard-orchestrator': 'false',
31+
'wizard-react-router': 'true',
32+
}),
33+
).toEqual({
34+
'wizard-orchestrator': 'true',
35+
'wizard-next-v2': 'legacy',
36+
'wizard-react-router': 'true',
37+
});
38+
});
39+
40+
it('fails loudly on malformed JSON instead of testing live flags', () => {
41+
process.env[ENV_KEY] = 'wizard-orchestrator=true';
42+
expect(() => applyCiFlagOverrides({})).toThrow(/not valid JSON/);
43+
});
44+
});
45+
46+
describe('in production builds', () => {
47+
it('is inert: overrides are ignored even when the env var is set', () => {
48+
const prevNodeEnv = process.env.NODE_ENV;
49+
process.env.NODE_ENV = 'production';
50+
process.env[ENV_KEY] = JSON.stringify({ 'wizard-orchestrator': true });
51+
let result: Record<string, string> | undefined;
52+
jest.isolateModules(() => {
53+
// eslint-disable-next-line @typescript-eslint/no-var-requires
54+
const prod = require('@utils/ci-flag-overrides') as {
55+
applyCiFlagOverrides: typeof applyCiFlagOverrides;
56+
};
57+
result = prod.applyCiFlagOverrides({ 'wizard-orchestrator': 'false' });
58+
});
59+
process.env.NODE_ENV = prevNodeEnv;
60+
expect(result).toEqual({ 'wizard-orchestrator': 'false' });
61+
});
62+
});
63+
});

src/utils/analytics.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { ApiUser } from '@lib/api';
99
import { v4 as uuidv4 } from 'uuid';
1010
import { IS_PRODUCTION_BUILD } from '@env';
1111
import { debug, logToFile } from './debug';
12+
import { applyCiFlagOverrides } from './ci-flag-overrides';
1213

1314
/**
1415
* Extract a standard property bag from the current session.
@@ -211,6 +212,7 @@ export class Analytics {
211212
if (this.activeFlags !== null) {
212213
return this.activeFlags;
213214
}
215+
const out: Record<string, string> = {};
214216
try {
215217
const distinctId = this.distinctId ?? this.anonymousId;
216218
logToFile('[flags] evaluating as', {
@@ -222,18 +224,23 @@ export class Analytics {
222224
personProperties: this.flagPersonProperties(),
223225
});
224226
const flags = result.featureFlags ?? {};
225-
const out: Record<string, string> = {};
226227
for (const [key, value] of Object.entries(flags)) {
227228
if (value === undefined) continue;
228229
out[key] = typeof value === 'boolean' ? String(value) : String(value);
229230
}
230-
this.activeFlags = out;
231-
logToFile('[flags] evaluated', out);
232-
return out;
233231
} catch (error) {
234232
debug('Failed to get all feature flags:', error);
235-
return {};
233+
this.captureException(
234+
error instanceof Error ? error : new Error(String(error)),
235+
{ step: 'get_all_flags' },
236+
);
236237
}
238+
// Outside the fetch guard on purpose: a malformed CI override must fail
239+
// the run loudly, and a valid one applies even when the fetch failed —
240+
// CI routing stays deterministic either way.
241+
this.activeFlags = applyCiFlagOverrides(out);
242+
logToFile('[flags] evaluated', this.activeFlags);
243+
return this.activeFlags;
237244
}
238245

239246
async shutdown(status: 'success' | 'error' | 'cancelled') {

src/utils/ci-flag-overrides.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* CI-only feature-flag overrides.
3+
*
4+
* CI must route deterministically: a run that tests the orchestrator arm says
5+
* so explicitly instead of depending on a live feature flag someone can edit
6+
* mid-week. `WIZARD_CI_FLAG_OVERRIDES` is a JSON object of flag key →
7+
* value, merged over whatever PostHog returned.
8+
*
9+
* The override path exists only in CI builds (`pnpm build:ci`). Published
10+
* builds inline NODE_ENV as the literal "production", the guard below
11+
* collapses, and tsdown strips the rest from the bundle — and the smoke test
12+
* asserts the env var's name is physically absent from production output, so
13+
* this can never quietly become a production surface.
14+
*/
15+
import { runtimeEnv } from '@env';
16+
import { logToFile } from './debug';
17+
18+
export function applyCiFlagOverrides(
19+
flags: Record<string, string>,
20+
): Record<string, string> {
21+
// Compared inline (not via env.ts's IS_PRODUCTION_BUILD) so tsdown replaces
22+
// it with a literal right here and the bundler can prove the rest of this
23+
// function unreachable in production builds. The smoke test enforces that.
24+
if (process.env.NODE_ENV === 'production') return flags;
25+
26+
const raw = runtimeEnv('WIZARD_CI_FLAG_OVERRIDES');
27+
if (!raw) return flags;
28+
29+
let overrides: Record<string, unknown>;
30+
try {
31+
overrides = JSON.parse(raw) as Record<string, unknown>;
32+
} catch {
33+
// A malformed override is a CI misconfiguration. Fail the run loudly
34+
// rather than silently testing whatever the live flags happen to say.
35+
throw new Error(
36+
'WIZARD_CI_FLAG_OVERRIDES is not valid JSON (expected {"flag-key": value, ...}).',
37+
);
38+
}
39+
40+
const merged = { ...flags };
41+
for (const [key, value] of Object.entries(overrides)) {
42+
merged[key] = String(value);
43+
}
44+
logToFile('[flags] CI overrides applied', overrides);
45+
return merged;
46+
}

0 commit comments

Comments
 (0)