Skip to content

Commit 6fa5a9e

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 2804e98 commit 6fa5a9e

5 files changed

Lines changed: 136 additions & 4 deletions

File tree

scripts/smoke-test.sh

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,27 @@ 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='POSTHOG_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+
else
36+
if grep -q "$OVERRIDE_MARKER" ./dist/*.js; then
37+
echo 'Smoke test failed: CI flag-override code leaked into a production build' >&2
38+
exit 1
39+
fi
40+
fi
41+
42+
# ── 3. --ci rejected in production builds ────────────────────────────────────
2343
# build:ci sets WIZARD_BUILD_NODE_ENV=ci → --ci stays enabled → skip the check.
2444
if [ "${WIZARD_BUILD_NODE_ENV:-production}" = "ci" ]; then
2545
exit 0

src/env.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ export const IS_PRODUCTION_BUILD = process.env.NODE_ENV === 'production';
4141
type RuntimeEnvKey =
4242
// Wizard CLI configuration (yargs POSTHOG_WIZARD_ prefix)
4343
| 'POSTHOG_WIZARD_BENCHMARK_CONFIG'
44+
// CI-build-only flag overrides (see utils/ci-flag-overrides.ts)
45+
| 'POSTHOG_WIZARD_CI_FLAG_OVERRIDES'
4446
| 'POSTHOG_WIZARD_BENCHMARK_FILE'
4547
| 'POSTHOG_WIZARD_LOG_DIR'
4648
| 'POSTHOG_WIZARD_DEBUG'
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 = 'POSTHOG_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: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { WizardSession } from '@lib/wizard-session';
88
import type { ApiUser } from '@lib/api';
99
import { v4 as uuidv4 } from 'uuid';
1010
import { debug, logToFile } from './debug';
11+
import { applyCiFlagOverrides } from './ci-flag-overrides';
1112

1213
/**
1314
* Extract a standard property bag from the current session.
@@ -187,9 +188,9 @@ export class Analytics {
187188
if (value === undefined) continue;
188189
out[key] = typeof value === 'boolean' ? String(value) : String(value);
189190
}
190-
this.activeFlags = out;
191-
logToFile('[flags] evaluated', out);
192-
return out;
191+
this.activeFlags = applyCiFlagOverrides(out);
192+
logToFile('[flags] evaluated', this.activeFlags);
193+
return this.activeFlags;
193194
} catch (error) {
194195
debug('Failed to get all feature flags:', error);
195196
return {};

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. `POSTHOG_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('POSTHOG_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+
'POSTHOG_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)