Skip to content
Merged
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
118 changes: 106 additions & 12 deletions scripts/release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,66 @@ const extractDescription = (prBody: string): string | undefined => {
return desc.length > MAX_DESC_LENGTH ? `${desc.slice(0, MAX_DESC_LENGTH)}...` : desc
}

const parseEnvFeatureFlags = (filePath: string): Record<string, boolean> => {
const flags: Record<string, boolean> = {}
if (!fs.existsSync(filePath)) return flags
const content = fs.readFileSync(filePath, 'utf-8')
for (const line of content.split('\n')) {
const trimmed = line.trim()
if (trimmed.startsWith('#') || !trimmed.includes('=')) continue
const [key, ...rest] = trimmed.split('=')
if (!key.startsWith('VITE_FEATURE_')) continue
const value = rest.join('=').trim().toLowerCase()
Comment on lines +121 to +123
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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 = truekey = "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.

if (value === 'true') flags[key] = true
else if (value === 'false') flags[key] = false
}
return flags
}

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 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()
}
Comment on lines +149 to +166
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.


const buildReleasePrompt = (
version: string,
messages: string[],
prBodies: Map<number, string>,
devOnlyFlags: string[],
privateDisabledFlags: string[],
): string => {
const commitList = messages
.map(msg => {
Expand All @@ -128,26 +184,50 @@ const buildReleasePrompt = (
})
.join('\n')

const devOnlySection =
devOnlyFlags.length > 0
? `\n\n## Dev-only feature flags (enabled in dev, disabled in production)\n\n${devOnlyFlags
.map(f => `- ${f}`)
.join('\n')}`
: ''

const privateDisabledSection =
privateDisabledFlags.length > 0
? `\n\n## Private-build disabled flags (enabled in production, disabled in private build)\n\n${privateDisabledFlags
.map(f => `- ${f}`)
.join('\n')}`
: ''

return `You are a release notes generator for ShapeShift Web, a decentralized crypto exchange.

Given the commit list below for ${version}, produce grouped release notes in markdown.
Given the commit list below for ${version}, produce grouped release notes in markdown with two clearly separated top-level sections.

## Rules

1. Group related commits under descriptive ## headings by feature domain (e.g. "TON chain + Stonfi swapper", "Yield improvements", "BigAmount migration")
2. List each commit as a bullet under its group, preserving the original text and PR number
3. After the bullet list, write 1-2 sentences summarizing what changed and what to test
4. For commits that are clearly behind a feature flag (title contains "behind feature flag" or "under flag"), note **under flag, no testing required**
### Section 1: "# Production changes - testing required"
1. Contains all PRs/commits that affect production code paths
2. Group related commits under descriptive ## headings by feature domain (e.g. "TON chain + Stonfi swapper", "Yield improvements", "BigAmount migration")
3. List each commit as a bullet under its group, preserving the original text and PR number
4. After the bullet list, write 1-2 sentences summarizing what changed and what to test
5. For internal refactors with no user-facing changes (e.g. migrations, type changes, selector renames), note **regression testing only** and what to sanity-check
6. For dependency bumps, CI fixes, infra, docker, CSP, and asset data regeneration, group under "## Fixes, deps, and infra" with **no testing required**
7. Merge/backmerge commits (e.g. "Merge branch 'main' into develop") should be silently dropped
8. Keep testing notes brief and actionable - what a QA person should click on, not implementation details
9. Use present tense for summaries ("Enables TON chain" not "Enabled TON chain")
10. Do NOT use emdashes. Use regular hyphens.
7. If a commit relates to a feature listed in the private-build disabled flags below, append "**Note: disabled in private build.**" to its testing notes within the production section

### Section 2: "# Dev/local only - no production testing required"
8. Contains all PRs/commits that are gated behind dev-only feature flags listed below
9. Match commits to dev-only flags by feature name (e.g. "Celo" matches CELO, "agentic chat" matches AGENTIC_CHAT, "Mantle" matches MANTLE, "Across" matches ACROSS_SWAP, etc.)
10. Commits whose title explicitly says "behind feature flag" or "under flag" also belong here
11. Group by feature domain with brief description only - no testing notes needed since these are not visible in production

### General rules
12. Merge/backmerge commits (e.g. "Merge branch 'main' into develop") should be silently dropped
13. Keep testing notes brief and actionable - what a QA person should click on, not implementation details
14. Use present tense for summaries ("Enables TON chain" not "Enabled TON chain")
15. Do NOT use emdashes. Use regular hyphens.

## Commits

${commitList}`
${commitList}${devOnlySection}${privateDisabledSection}`
}

const runClaude = (promptPath: string): Promise<string> => {
Expand Down Expand Up @@ -203,8 +283,10 @@ const generateReleaseSummary = async (
version: string,
messages: string[],
prBodies: Map<number, string>,
devOnlyFlags: string[],
privateDisabledFlags: string[],
): Promise<string | null> => {
const prompt = buildReleasePrompt(version, messages, prBodies)
const prompt = buildReleasePrompt(version, messages, prBodies, devOnlyFlags, privateDisabledFlags)
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shapeshift-release-'))
const promptPath = path.join(tmpDir, 'prompt.txt')

Expand Down Expand Up @@ -310,7 +392,19 @@ const doRegularRelease = async () => {
const prBodies = prNumbers.length > 0 ? await fetchPrBodies(prNumbers) : new Map<number, string>()
console.log(chalk.green(`Fetched ${prBodies.size}/${prNumbers.length} PR descriptions.`))

const summary = await generateReleaseSummary(nextVersion, messages, prBodies)
const devOnlyFlags = getDevOnlyFlags()
console.log(chalk.green(`Detected ${devOnlyFlags.length} dev-only feature flags.`))

const privateDisabledFlags = getPrivateDisabledFlags()
console.log(chalk.green(`Detected ${privateDisabledFlags.length} private-build disabled flags.`))

const summary = await generateReleaseSummary(
nextVersion,
messages,
prBodies,
devOnlyFlags,
privateDisabledFlags,
)
const prBody = summary ?? messages.join('\n')

if (summary) {
Expand Down
Loading