diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c8f35327..13c1ca3c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/package.json b/package.json index fc4ac3cb..33967ce7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/extension/vite.config.ts b/packages/extension/vite.config.ts index 3cab05ff..456b7476 100644 --- a/packages/extension/vite.config.ts +++ b/packages/extension/vite.config.ts @@ -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) } - } + }, + copyPublicDir: true + }, + server: { + port: 5174, + https: {} } }) diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts index 357e63b6..ecc3db7e 100644 --- a/packages/web/vite.config.ts +++ b/packages/web/vite.config.ts @@ -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) + 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') + } } }) diff --git a/scripts/check-extension-release-secrets.mjs b/scripts/check-extension-release-secrets.mjs new file mode 100644 index 00000000..0de10b56 --- /dev/null +++ b/scripts/check-extension-release-secrets.mjs @@ -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 '' + 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).`)