feat: add feature flag awareness to release script#12021
feat: add feature flag awareness to release script#120210xApotheosis merged 2 commits intodevelopfrom
Conversation
The release script now parses .env, .env.development, and .env.production to detect dev-only feature flags and produces two-section output separating production changes from dev/local-only changes gated behind flags. Co-Authored-By: Claude Opus 4.6 <[email protected]>
📝 WalkthroughWalkthroughThis PR adds parsing utilities for VITE_FEATURE_* environment variables from .env files and extends the AI-driven release summary generation flow to incorporate feature-flag information, including dev-only flags and private-build disabled flags, in the release prompt. Changes
Estimated code review effort🎯 2 (Simple) | ⏱️ ~15 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Annotates production commits related to features disabled in the private build (Mixpanel, Chatwoot) so QA knows these don't apply to the private deployment. Co-Authored-By: Claude Opus 4.6 <[email protected]>
|
I'm going to push this one through so that I can then check the new hotfix script update on this PR and confirm that it works. |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
scripts/release.ts (1)
130-166: Extract shared flag-diff logic to avoid duplication
getDevOnlyFlagsandgetPrivateDisabledFlagsare structurally identical — same rootDir resolution, same spread-merge pattern, same key-filtering loop. A single helper eliminates ~25 lines of duplication and makes the intent of each call site immediately clear.♻️ Suggested refactor
+const ROOT_DIR = path.resolve(__dirname, '..') + +const computeFeatureFlagDiff = (enabledPaths: string[], disabledPaths: string[]): string[] => { + const loadEffective = (paths: string[]) => + paths.reduce<Record<string, boolean>>( + (acc, filePath) => ({ ...acc, ...parseEnvFeatureFlags(filePath) }), + {}, + ) + const enabledFlags = loadEffective(enabledPaths) + const disabledFlags = loadEffective(disabledPaths) + const allKeys = new Set([...Object.keys(enabledFlags), ...Object.keys(disabledFlags)]) + return Array.from(allKeys) + .filter(key => enabledFlags[key] === true && disabledFlags[key] !== true) + .map(key => key.replace('VITE_FEATURE_', '')) + .sort() +} -const getDevOnlyFlags = (): string[] => { - const rootDir = path.resolve(__dirname, '..') - const baseFlags = parseEnvFeatureFlags(path.join(rootDir, '.env')) - const prodOverrides = parseEnvFeatureFlags(path.join(rootDir, '.env.production')) - const devOverrides = parseEnvFeatureFlags(path.join(rootDir, '.env.development')) - const prodFlags: Record<string, boolean> = { ...baseFlags, ...prodOverrides } - const devFlags: Record<string, boolean> = { ...baseFlags, ...devOverrides } - const allKeys = new Set([...Object.keys(prodFlags), ...Object.keys(devFlags)]) - const devOnly: string[] = [] - for (const key of allKeys) { - if (devFlags[key] === true && prodFlags[key] !== true) { - devOnly.push(key.replace('VITE_FEATURE_', '')) - } - } - return devOnly.sort() -} +const getDevOnlyFlags = (): string[] => + computeFeatureFlagDiff( + [path.join(ROOT_DIR, '.env'), path.join(ROOT_DIR, '.env.development')], + [path.join(ROOT_DIR, '.env'), path.join(ROOT_DIR, '.env.production')], + ) -const getPrivateDisabledFlags = (): string[] => { - const rootDir = path.resolve(__dirname, '..') - const baseFlags = parseEnvFeatureFlags(path.join(rootDir, '.env')) - const prodOverrides = parseEnvFeatureFlags(path.join(rootDir, '.env.production')) - const privateOverrides = parseEnvFeatureFlags(path.join(rootDir, '.env.private')) - const prodFlags: Record<string, boolean> = { ...baseFlags, ...prodOverrides } - const privateFlags: Record<string, boolean> = { ...baseFlags, ...privateOverrides } - const allKeys = new Set([...Object.keys(prodFlags), ...Object.keys(privateFlags)]) - const privateDisabled: string[] = [] - for (const key of allKeys) { - if (prodFlags[key] === true && privateFlags[key] !== true) { - privateDisabled.push(key.replace('VITE_FEATURE_', '')) - } - } - return privateDisabled.sort() -} +const getPrivateDisabledFlags = (): string[] => { + const privatePath = path.join(ROOT_DIR, '.env.private') + if (!fs.existsSync(privatePath)) { + console.log(chalk.yellow('No .env.private found; skipping private-build disabled flags.')) + return [] + } + return computeFeatureFlagDiff( + [path.join(ROOT_DIR, '.env'), path.join(ROOT_DIR, '.env.production')], + [path.join(ROOT_DIR, '.env'), privatePath], + ) +}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@scripts/release.ts` around lines 130 - 166, Both getDevOnlyFlags and getPrivateDisabledFlags duplicate the same logic; extract a reusable helper (e.g., computeFlagDiff) that accepts rootDir, two override filenames or two flag maps and a predicate describing which keys to keep, calls parseEnvFeatureFlags, merges base + overrides into two records, computes the union of keys, filters using the provided condition, strips the "VITE_FEATURE_" prefix, sorts and returns the array; then implement getDevOnlyFlags to call computeFlagDiff with '.env.development' and a predicate (dev true && prod not true) and getPrivateDisabledFlags with '.env.private' and a predicate (prod true && private not true), reusing parseEnvFeatureFlags and keeping existing return values.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@scripts/release.ts`:
- Around line 121-123: The split result leaves whitespace on the key and doesn't
strip quotes from values, causing mismatched keys and ignored quoted booleans;
in the parsing logic in scripts/release.ts (the const [key, ...rest] =
trimmed.split('=') block) trim the key (e.g. key = key.trim()) before the
VITE_FEATURE_ check and normalize the value by trimming, stripping surrounding
single or double quotes, then lowercasing (e.g. value =
unquote(rest.join('=').trim()).toLowerCase()) so quoted booleans like "true" are
recognized consistently across .env files.
- Around line 149-166: The function getPrivateDisabledFlags can produce false
positives when .env.private is missing because parseEnvFeatureFlags returns {} —
check for the presence of the private env file before using its parsed flags:
compute the privatePath (path.join(rootDir, '.env.private')), if
fs.existsSync(privatePath) is false then return an empty array early (or skip
comparing privateFlags) to avoid treating baseFlags as an intentional private
override; update the logic around privateOverrides/privateFlags in
getPrivateDisabledFlags to only parse and compare when the private file exists.
---
Nitpick comments:
In `@scripts/release.ts`:
- Around line 130-166: Both getDevOnlyFlags and getPrivateDisabledFlags
duplicate the same logic; extract a reusable helper (e.g., computeFlagDiff) that
accepts rootDir, two override filenames or two flag maps and a predicate
describing which keys to keep, calls parseEnvFeatureFlags, merges base +
overrides into two records, computes the union of keys, filters using the
provided condition, strips the "VITE_FEATURE_" prefix, sorts and returns the
array; then implement getDevOnlyFlags to call computeFlagDiff with
'.env.development' and a predicate (dev true && prod not true) and
getPrivateDisabledFlags with '.env.private' and a predicate (prod true &&
private not true), reusing parseEnvFeatureFlags and keeping existing return
values.
ℹ️ Review info
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Disabled knowledge base sources:
- Linear integration is disabled
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (1)
scripts/release.ts
| const [key, ...rest] = trimmed.split('=') | ||
| if (!key.startsWith('VITE_FEATURE_')) continue | ||
| const value = rest.join('=').trim().toLowerCase() |
There was a problem hiding this comment.
key is not trimmed after splitting — potential whitespace mismatch across files
rest.join('=').trim() normalises the value but key retains any whitespace that surrounds = (e.g. VITE_FEATURE_FOO = true → key = "VITE_FEATURE_FOO "). When the same flag appears in two different .env files with inconsistent spacing, the spread merge treats them as distinct keys, silently producing wrong flag resolution. Additionally, quoted booleans (VITE_FEATURE_FOO="true") are silently ignored.
🛠 Proposed fix
- const [key, ...rest] = trimmed.split('=')
- if (!key.startsWith('VITE_FEATURE_')) continue
- const value = rest.join('=').trim().toLowerCase()
+ const [rawKey, ...rest] = trimmed.split('=')
+ const key = rawKey.trim()
+ if (!key.startsWith('VITE_FEATURE_')) continue
+ const value = rest.join('=').trim().replace(/^["']|["']$/g, '').toLowerCase()🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@scripts/release.ts` around lines 121 - 123, The split result leaves
whitespace on the key and doesn't strip quotes from values, causing mismatched
keys and ignored quoted booleans; in the parsing logic in scripts/release.ts
(the const [key, ...rest] = trimmed.split('=') block) trim the key (e.g. key =
key.trim()) before the VITE_FEATURE_ check and normalize the value by trimming,
stripping surrounding single or double quotes, then lowercasing (e.g. value =
unquote(rest.join('=').trim()).toLowerCase()) so quoted booleans like "true" are
recognized consistently across .env files.
| const getPrivateDisabledFlags = (): string[] => { | ||
| const rootDir = path.resolve(__dirname, '..') | ||
| const baseFlags = parseEnvFeatureFlags(path.join(rootDir, '.env')) | ||
| const prodOverrides = parseEnvFeatureFlags(path.join(rootDir, '.env.production')) | ||
| const privateOverrides = parseEnvFeatureFlags(path.join(rootDir, '.env.private')) | ||
|
|
||
| const prodFlags: Record<string, boolean> = { ...baseFlags, ...prodOverrides } | ||
| const privateFlags: Record<string, boolean> = { ...baseFlags, ...privateOverrides } | ||
|
|
||
| const allKeys = new Set([...Object.keys(prodFlags), ...Object.keys(privateFlags)]) | ||
| const privateDisabled: string[] = [] | ||
| for (const key of allKeys) { | ||
| if (prodFlags[key] === true && privateFlags[key] !== true) { | ||
| privateDisabled.push(key.replace('VITE_FEATURE_', '')) | ||
| } | ||
| } | ||
| return privateDisabled.sort() | ||
| } |
There was a problem hiding this comment.
Silent incorrect output when .env.private is absent
parseEnvFeatureFlags returns {} for missing files, so if .env.private doesn't exist, privateFlags collapses to baseFlags. Any flag that is false in .env but true in .env.production (i.e. enabled only via the production override) then satisfies prodFlags[key] === true && privateFlags[key] !== true, and gets incorrectly injected into the Claude prompt as "disabled in private build". This silently causes incorrect QA-skip annotations on otherwise live production features.
🛠 Proposed fix — early-return when private env is absent
const getPrivateDisabledFlags = (): string[] => {
const rootDir = path.resolve(__dirname, '..')
- const baseFlags = parseEnvFeatureFlags(path.join(rootDir, '.env'))
- const prodOverrides = parseEnvFeatureFlags(path.join(rootDir, '.env.production'))
- const privateOverrides = parseEnvFeatureFlags(path.join(rootDir, '.env.private'))
+ const privatePath = path.join(rootDir, '.env.private')
+ if (!fs.existsSync(privatePath)) {
+ console.log(chalk.yellow('No .env.private found; skipping private-build disabled flags.'))
+ return []
+ }
+ const baseFlags = parseEnvFeatureFlags(path.join(rootDir, '.env'))
+ const prodOverrides = parseEnvFeatureFlags(path.join(rootDir, '.env.production'))
+ const privateOverrides = parseEnvFeatureFlags(privatePath)
const prodFlags: Record<string, boolean> = { ...baseFlags, ...prodOverrides }
const privateFlags: Record<string, boolean> = { ...baseFlags, ...privateOverrides }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@scripts/release.ts` around lines 149 - 166, The function
getPrivateDisabledFlags can produce false positives when .env.private is missing
because parseEnvFeatureFlags returns {} — check for the presence of the private
env file before using its parsed flags: compute the privatePath
(path.join(rootDir, '.env.private')), if fs.existsSync(privatePath) is false
then return an empty array early (or skip comparing privateFlags) to avoid
treating baseFlags as an intentional private override; update the logic around
privateOverrides/privateFlags in getPrivateDisabledFlags to only parse and
compare when the private file exists.
* feat: add feature flag awareness to release script The release script now parses .env, .env.development, and .env.production to detect dev-only feature flags and produces two-section output separating production changes from dev/local-only changes gated behind flags. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: add private build environment awareness to release script Annotates production commits related to features disabled in the private build (Mixpanel, Chatwoot) so QA knows these don't apply to the private deployment. Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
Description
The release script (
scripts/release.ts) generates AI-powered release summaries for the ops team. Currently, it has no awareness of which features are gated behind dev-only flags vs production-enabled flags. Rule 4 in the Claude prompt only catches commits whose titles explicitly say "behind feature flag" - it doesn't read the actual.envfiles. This means features like Celo, Agentic Chat, and ~25 other dev-only chains/swappers are not properly annotated, causing the ops team to over-test features that aren't even visible in production.This PR adds env file parsing to the release script so it can automatically detect dev-only feature flags and produce two clearly separated output sections:
Example of updated output:
What changed
parseEnvFeatureFlags(filePath)- Reads a.envfile and extractsVITE_FEATURE_*keys with boolean valuesgetDevOnlyFlags()- Computes effective prod flags (.env+.env.production) vs dev flags (.env+.env.development), returns flags where dev=true and prod!=truebuildReleasePrompt- Updated to accept dev-only flags and restructured prompt rules for two-section outputdoRegularReleaseupdated to compute and pass dev-only flagsCurrently detects 27 dev-only flags (e.g. CELO, AGENTIC_CHAT, MANTLE, FLOWEVM, ACROSS_SWAP) while correctly excluding prod-enabled flags (SOLANA, ARBITRUM, BASE, etc.).
Issue (if applicable)
N/A
Risk
Minimal. Changes are isolated to
scripts/release.tswhich is a CLI dev tool only - no runtime, user-facing, or on-chain impact. The script still falls back to raw commit list if Claude generation fails.None. This only affects the release notes generation tooling.
Testing
Engineering
npx ts-node scripts/release.ts- it should still prompt for release type and generate summariesyarn type-checkpasses (no new errors in scripts/release.ts)yarn lint --fixpasses (no new errors)Operations
This is a dev tooling change only - no user-facing impact. The output of the release script will now be more useful to operations by clearly separating what needs production testing from what doesn't.
Screenshots (if applicable)
Example output with new format (v1.1010.0 commits):
Summary by CodeRabbit