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:
1 parent
7f04dcc52c
commit
269df1dcf7
5 files changed
+331
-101
No files matched your search
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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')
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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).`)
|
||||
Reference in new issue
Block a user