fix(release): isolate extension build env

- Split extension release builds from web env loading.
- Add a release secret guard for extension packaging.
- Keep web and extension Vite configs from leaking incompatible env.
This commit is contained in:
linshen committed 2026-05-15 23:38:52 +08:00
1 parent 7f04dcc52c
commit 269df1dcf7
5 files changed
+331 -101

No files matched your search

+99 -1
View File
@@ -433,8 +433,99 @@ jobs:
--repo "${{ github.repository }}" \
--clobber
# 构建 Chrome 插件 ZIP(只允许从 GitHub Release 下载后上传 Chrome Web Store)
build-extension:
needs: [prepare-release]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: 检出代码
uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-tags: true
- name: 安装 pnpm
uses: pnpm/action-setup@v4
with:
run_install: false
- name: 设置 Node.js 环境
uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: 确认发布构建环境未携带本地 env 文件
run: |
blocked_env_files=(
.env
.env.local
.env.production
.env.production.local
)
for file in "${blocked_env_files[@]}"; do
if [ -e "$file" ]; then
echo "Error: release extension build must not use local env file: $file"
exit 1
fi
done
- name: 校验 Chrome 插件 manifest 版本
env:
EXPECTED_VERSION: ${{ needs.prepare-release.outputs.version_no_prefix }}
run: |
MANIFEST_VERSION=$(jq -r '.version' packages/extension/public/manifest.json)
echo "Expected manifest version: $EXPECTED_VERSION"
echo "Actual manifest version: $MANIFEST_VERSION"
if [ "$MANIFEST_VERSION" != "$EXPECTED_VERSION" ]; then
echo "Error: Chrome extension manifest version does not match release tag"
exit 1
fi
- name: 安装依赖
run: pnpm install --frozen-lockfile
- name: 构建 Chrome 插件
run: |
pnpm build:core
pnpm build:ui
pnpm build:ext
- name: 扫描插件产物中的敏感信息
run: pnpm check:extension-release-secrets
- name: 打包 Chrome 插件 ZIP
env:
VERSION: ${{ needs.prepare-release.outputs.version }}
run: |
ZIP_NAME="PromptOptimizer-chrome-extension-${VERSION}.zip"
echo "ZIP_NAME=$ZIP_NAME" >> "$GITHUB_ENV"
test -f packages/extension/dist/manifest.json
(
cd packages/extension/dist
zip -r "$GITHUB_WORKSPACE/$ZIP_NAME" .
)
unzip -t "$ZIP_NAME"
ls -lh "$ZIP_NAME"
- name: 上传 Chrome 插件 Release Asset
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.prepare-release.outputs.version }}
run: |
gh release upload "$VERSION" \
"$ZIP_NAME" \
--repo "${{ github.repository }}" \
--clobber
publish-release:
needs: [prepare-release, build-windows, build-macos, build-linux]
needs: [prepare-release, build-windows, build-macos, build-linux, build-extension]
runs-on: ubuntu-latest
permissions:
contents: write
@@ -471,6 +562,7 @@ jobs:
RELEASE_ASSET_NAMES=$(echo "$RELEASE_JSON" | jq -r '.assets[].name')
WINDOWS_BLOCKMAP_COUNT=$(echo "$RELEASE_ASSET_NAMES" | grep -Ec '^PromptOptimizer-.*-win-.*\.exe\.blockmap$' || true)
MAC_ZIP_BLOCKMAP_COUNT=$(echo "$RELEASE_ASSET_NAMES" | grep -Ec '^PromptOptimizer-.*-mac-.*\.zip\.blockmap$' || true)
EXTENSION_ZIP_COUNT=$(echo "$RELEASE_ASSET_NAMES" | grep -Ec '^PromptOptimizer-chrome-extension-v?[0-9]+\.[0-9]+\.[0-9]+.*\.zip$' || true)
if [ "$RELEASE_NAME" != "$EXPECTED_NAME" ]; then
echo "Release name mismatch: expected '$EXPECTED_NAME', got '$RELEASE_NAME'"
@@ -503,3 +595,9 @@ jobs:
echo "$RELEASE_ASSET_NAMES"
exit 1
fi
if [ "$EXTENSION_ZIP_COUNT" -lt 1 ]; then
echo "Release is missing Chrome extension zip asset"
echo "$RELEASE_ASSET_NAMES"
exit 1
fi
+1
View File
@@ -31,6 +31,7 @@
"test:repo": "node --test scripts/run-many.test.mjs scripts/check-locale-parity.test.mjs scripts/check-no-chinese-runtime.test.mjs scripts/package-scripts.test.mjs scripts/desktop-ipc-handlers.test.mjs && pnpm check:locale && pnpm check:no-chinese-runtime",
"check:locale": "node scripts/check-locale-parity.mjs",
"check:no-chinese-runtime": "node scripts/check-no-chinese-runtime.mjs",
"check:extension-release-secrets": "node scripts/check-extension-release-secrets.mjs packages/extension/dist",
"test:e2e": "playwright test",
"test:e2e:smart": "node scripts/smart-e2e.js",
"test:e2e:gate": "node scripts/run-e2e-group.js gate",
+28 -48
View File
@@ -1,59 +1,39 @@
import { defineConfig, loadEnv } from 'vite'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import basicSsl from '@vitejs/plugin-basic-ssl'
import { DEFAULT_VITE_ENV } from '../core/src/utils/default-env'
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
const monorepoRoot = resolve(__dirname, '../..')
const env = loadEnv(mode, monorepoRoot)
const processEnv = {
...DEFAULT_VITE_ENV,
...env,
}
return {
envDir: monorepoRoot,
plugins: [vue(), basicSsl()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
'@prompt-optimizer/ui': resolve(__dirname, '../ui')
},
export default defineConfig({
plugins: [vue(), basicSsl()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
'@prompt-optimizer/ui': resolve(__dirname, '../ui')
},
base: './', // 使用相对路径
build: {
outDir: 'dist',
rollupOptions: {
input: {
popup: resolve(__dirname, 'index.html')
},
output: {
entryFileNames: `assets/[name].js`,
chunkFileNames: `assets/[name].js`,
assetFileNames: (assetInfo) => {
if (assetInfo.name === 'background.js') {
return 'background.js';
}
return `assets/[name].[ext]`;
},
base: './', // 使用相对路径
build: {
outDir: 'dist',
rollupOptions: {
input: {
popup: resolve(__dirname, 'index.html')
},
output: {
entryFileNames: `assets/[name].js`,
chunkFileNames: `assets/[name].js`,
assetFileNames: (assetInfo) => {
if (assetInfo.name === 'background.js') {
return 'background.js';
}
return `assets/[name].[ext]`;
}
},
copyPublicDir: true
},
server: {
port: 5174,
https: {}
},
define: {
'process.env': {
NODE_ENV: JSON.stringify(process.env.NODE_ENV || 'development'),
...Object.keys(processEnv).reduce((acc, key) => {
acc[key] = processEnv[key as keyof typeof processEnv];
return acc;
}, {} as Record<string, string>)
}
}
},
copyPublicDir: true
},
server: {
port: 5174,
https: {}
}
})
+30 -52
View File
@@ -1,62 +1,40 @@
import { defineConfig, loadEnv } from 'vite'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import path from 'path'
import { DEFAULT_VITE_ENV } from '../core/src/utils/default-env'
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
// 在 monorepo 中,脚本可能从不同的 cwd 启动;不要依赖 process.cwd() 去定位 .env。
// 这里用配置文件所在位置推导出 monorepo root,并让 Vite 将 VITE_* 注入 import.meta.env。
const monorepoRoot = resolve(__dirname, '../..')
const env = loadEnv(mode, monorepoRoot)
const processEnv = {
...DEFAULT_VITE_ENV,
...env,
}
return {
envDir: monorepoRoot,
plugins: [vue()],
server: {
port: 18181,
host: true,
fs: {
// 允许为工作区依赖提供服务
allow: ['..']
},
hmr: true,
watch: {
// 确保监视monorepo中其他包的变化
ignored: ['!**/node_modules/@prompt-optimizer/**']
}
export default defineConfig({
plugins: [vue()],
server: {
port: 18181,
host: true,
fs: {
// 允许为工作区依赖提供服务
allow: ['..']
},
build: {
rollupOptions: {
input: {
main: resolve(__dirname, 'index.html')
}
}
},
publicDir: 'public',
resolve: {
preserveSymlinks: true,
alias: {
'@': resolve(__dirname, 'src'),
'@prompt-optimizer/core': path.resolve(__dirname, '../core'),
'@prompt-optimizer/ui': path.resolve(__dirname, '../ui'),
'@prompt-optimizer/web': path.resolve(__dirname, '../web'),
'@prompt-optimizer/extension': path.resolve(__dirname, '../extension')
}
},
define: {
'process.env': {
NODE_ENV: JSON.stringify(process.env.NODE_ENV || 'development'),
...Object.keys(processEnv).reduce((acc, key) => {
acc[key] = processEnv[key as keyof typeof processEnv];
return acc;
}, {} as Record<string, string>)
hmr: true,
watch: {
// 确保监视monorepo中其他包的变化
ignored: ['!**/node_modules/@prompt-optimizer/**']
}
},
build: {
rollupOptions: {
input: {
main: resolve(__dirname, 'index.html')
}
}
},
publicDir: 'public',
resolve: {
preserveSymlinks: true,
alias: {
'@': resolve(__dirname, 'src'),
'@prompt-optimizer/core': path.resolve(__dirname, '../core'),
'@prompt-optimizer/ui': path.resolve(__dirname, '../ui'),
'@prompt-optimizer/web': path.resolve(__dirname, '../web'),
'@prompt-optimizer/extension': path.resolve(__dirname, '../extension')
}
}
})
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env node
import fs from 'node:fs'
import path from 'node:path'
const target = process.argv[2] || path.join('packages', 'extension', 'dist')
const rootDir = process.cwd()
const targetPath = path.resolve(rootDir, target)
const TEXT_EXTENSIONS = new Set([
'.css',
'.html',
'.js',
'.json',
'.map',
'.mjs',
'.txt',
])
const SECRET_PATTERNS = [
{
name: 'OpenAI-compatible API key',
regex: /\bsk-[A-Za-z0-9_-]{12,}\b/g,
allow: (value) => value.startsWith('sk-border'),
},
{
name: 'OpenRouter API key',
regex: /\bsk-or-v1-[A-Za-z0-9_-]{12,}\b/g,
},
{
name: 'xAI API key',
regex: /\bxai-[A-Za-z0-9_-]{12,}\b/g,
},
{
name: 'UUID-like secret',
regex: /\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b/g,
allow: (value) =>
value === '00000000-0000-0000-0000-000000000000' ||
value === '10000000-1000-4000-8000-100000000000' ||
value === 'ffffffff-ffff-ffff-ffff-ffffffffffff',
},
]
const BLOCKED_LITERALS = [
'http://localhost:18300',
'http://127.0.0.1:18300',
'http://127.0.0.1:8317',
'VITE_LOCAL_DEV:`true`',
'VITE_LOCAL_DEV:"true"',
"VITE_LOCAL_DEV:'true'",
]
const SENSITIVE_ENV_KEY = /^(VITE_.*(?:API_KEY|API_TOKEN|SECRET|PASSWORD|CUSTOM_API|OPENAI|DEEPSEEK|SILICONFLOW|OPENROUTER|DASHSCOPE|GROK|SEEDREAM|GEMINI|ANTHROPIC|ZHIPU|MODELSCOPE|MINIMAX|CF_))$/u
const SAFE_ENV_VALUES = new Set(['true', 'false', '1', '0', 'development', 'production'])
function mask(value) {
if (value.length <= 12) return '<set>'
return `${value.slice(0, 8)}...${value.slice(-4)}`
}
function parseEnvFile(filePath) {
if (!fs.existsSync(filePath)) return []
return fs
.readFileSync(filePath, 'utf8')
.split(/\r?\n/u)
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#') && line.includes('='))
.map((line) => {
const separator = line.indexOf('=')
const key = line.slice(0, separator).trim()
let value = line.slice(separator + 1).trim()
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
}
return { key, value }
})
.filter(({ key, value }) => {
if (!key.startsWith('VITE_')) return false
if (!SENSITIVE_ENV_KEY.test(key)) return false
if (!value || value.length < 8) return false
if (SAFE_ENV_VALUES.has(value.toLowerCase())) return false
return true
})
}
function listFiles(currentPath) {
const stat = fs.statSync(currentPath)
if (stat.isFile()) return [currentPath]
if (!stat.isDirectory()) return []
return fs
.readdirSync(currentPath, { withFileTypes: true })
.flatMap((entry) => listFiles(path.join(currentPath, entry.name)))
}
function isTextFile(filePath) {
return TEXT_EXTENSIONS.has(path.extname(filePath).toLowerCase())
}
function findLine(text, index) {
return text.slice(0, index).split(/\r?\n/u).length
}
if (!fs.existsSync(targetPath)) {
console.error(`Extension release scan target does not exist: ${targetPath}`)
process.exit(1)
}
const envValues = [
...parseEnvFile(path.join(rootDir, '.env')),
...parseEnvFile(path.join(rootDir, '.env.local')),
]
const findings = []
const files = listFiles(targetPath).filter(isTextFile)
for (const file of files) {
const relativeFile = path.relative(rootDir, file)
const text = fs.readFileSync(file, 'utf8')
for (const literal of BLOCKED_LITERALS) {
const index = text.indexOf(literal)
if (index !== -1) {
findings.push({
file: relativeFile,
line: findLine(text, index),
type: 'blocked literal',
value: literal,
})
}
}
for (const pattern of SECRET_PATTERNS) {
for (const match of text.matchAll(pattern.regex)) {
const value = match[0]
if (pattern.allow?.(value)) continue
findings.push({
file: relativeFile,
line: findLine(text, match.index ?? 0),
type: pattern.name,
value: mask(value),
})
}
}
for (const { key, value } of envValues) {
const index = text.indexOf(value)
if (index === -1) continue
findings.push({
file: relativeFile,
line: findLine(text, index),
type: `local env value (${key})`,
value: mask(value),
})
}
}
if (findings.length > 0) {
console.error('Extension release secret scan failed:')
for (const finding of findings) {
console.error(
`- ${finding.file}:${finding.line} ${finding.type} ${finding.value}`
)
}
process.exit(1)
}
console.log(`Extension release secret scan passed (${files.length} text files checked).`)