diff --git a/.github/scripts/generate-release-notes.sh b/.github/scripts/generate-release-notes.sh index 64967619..af0d0e43 100755 --- a/.github/scripts/generate-release-notes.sh +++ b/.github/scripts/generate-release-notes.sh @@ -7,7 +7,9 @@ if [ -z "$TAG" ]; then exit 2 fi -release_notes_filter='^(chore|docs|ci)(\(.*\))?:' +# Mirrors SKIP_RELEASE_PATTERN in bump-electron(-beta).yml so the notes never +# list commits that wouldn't have triggered a release on their own. +release_notes_filter='^(chore|docs|ci|test|refactor|style)(\(.*\))?:' promotion_filter='^(fix: promote dev release fixes to master|chore: promote dev to master)' find_previous_tag() { @@ -66,5 +68,13 @@ if [ -z "$BODY" ]; then BODY="Bug fixes and improvements" fi -printf '%s\n' "$BODY" | node "$(dirname "$0")/translate-notes.js" +# Notes must never block a release: if node itself dies (missing binary, +# OOM, import-time error), fall back to the raw commit list instead of +# letting pipefail propagate a non-zero exit into release.yml. +if NOTES="$(printf '%s\n' "$BODY" | node "$(dirname "$0")/summarize-release-notes.mjs" "$TAG")"; then + printf '%s\n' "$NOTES" +else + echo "warning: summarize-release-notes.mjs failed, emitting raw commit list" >&2 + printf '%s\n' "$BODY" +fi diff --git a/.github/scripts/notify-webhook.sh b/.github/scripts/notify-webhook.sh new file mode 100755 index 00000000..70273ab6 --- /dev/null +++ b/.github/scripts/notify-webhook.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Best-effort plain-text webhook notification (ntfy-compatible: the body is +# the message). Silently no-ops when NOTIFY_WEBHOOK_URL is unset, and never +# fails the calling workflow. +set -u + +MESSAGE="${1:-}" +if [ -z "${NOTIFY_WEBHOOK_URL:-}" ] || [ -z "$MESSAGE" ]; then + exit 0 +fi + +curl -fsS -m 10 -X POST \ + -H "Content-Type: text/plain; charset=utf-8" \ + --data-binary "$MESSAGE" \ + "$NOTIFY_WEBHOOK_URL" >/dev/null \ + || echo "::warning::webhook notification failed (non-blocking)" +exit 0 diff --git a/.github/scripts/select-promote-candidate.sh b/.github/scripts/select-promote-candidate.sh new file mode 100755 index 00000000..b48bccc6 --- /dev/null +++ b/.github/scripts/select-promote-candidate.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Pick promotion candidates from dev that satisfy the soak window. +# +# The old rule ("dev HEAD must be >= 24h old") starved master during active +# weeks: every new dev commit reset the clock for the whole batch. Instead we +# promote the NEWEST first-parent dev commit older than the soak window — +# fresh commits keep soaking on dev and ride the next day's promotion. +# +# Env: +# MASTER_REF base ref (default refs/remotes/origin/master) +# DEV_REF candidate source (default refs/remotes/origin/dev) +# MIN_AGE_SECONDS soak window (default 86400) +# MAX_CANDIDATES output cap (default 10) +# NOW_EPOCH clock override for tests (default: now) +# FORCE "true" bypasses soak and emits dev tip (hotfix path) +# +# Output: up to MAX_CANDIDATES eligible SHAs, newest first, one per line. +# The caller walks the list looking for a CI-green commit. Empty output means +# nothing is eligible today. +set -euo pipefail + +MASTER_REF="${MASTER_REF:-refs/remotes/origin/master}" +DEV_REF="${DEV_REF:-refs/remotes/origin/dev}" +MIN_AGE_SECONDS="${MIN_AGE_SECONDS:-86400}" +MAX_CANDIDATES="${MAX_CANDIDATES:-10}" +NOW_EPOCH="${NOW_EPOCH:-$(date +%s)}" + +for VAR in MIN_AGE_SECONDS MAX_CANDIDATES NOW_EPOCH; do + if ! [[ "${!VAR}" =~ ^[0-9]+$ ]]; then + echo "error: $VAR must be a non-negative integer, got '${!VAR}'" >&2 + exit 2 + fi +done + +# Fail loudly on missing refs: a rev-list error inside the process +# substitution below would otherwise be swallowed (empty output looks like a +# normal "nothing eligible" soak skip and could mask a config error for weeks). +for REF in "$MASTER_REF" "$DEV_REF"; do + if ! git rev-parse --verify -q "${REF}^{commit}" >/dev/null; then + echo "error: ref not found: $REF" >&2 + exit 2 + fi +done + +if [ "${FORCE:-false}" = "true" ]; then + git rev-parse "$DEV_REF" + exit 0 +fi + +CUTOFF=$(( NOW_EPOCH - MIN_AGE_SECONDS )) + +# First-parent keeps us on dev's mainline (states dev actually passed +# through). That alone does NOT guarantee a fast-forward: after a sync-back +# merge (master merged INTO dev as a second parent), first-parent commits +# below the merge are not descendants of master, and pushing one would be +# rejected as non-ff. Filter each candidate explicitly. +COUNT=0 +while read -r SHA; do + if [ -z "$SHA" ]; then continue; fi + git merge-base --is-ancestor "$MASTER_REF" "$SHA" 2>/dev/null || continue + echo "$SHA" + COUNT=$(( COUNT + 1 )) + if [ "$COUNT" -ge "$MAX_CANDIDATES" ]; then break; fi +done < <( + git rev-list --first-parent --format="%H %ct" --no-commit-header \ + "${MASTER_REF}..${DEV_REF}" \ + | awk -v cutoff="$CUTOFF" '$2 <= cutoff { print $1 }' +) +exit 0 diff --git a/.github/scripts/summarize-release-notes.mjs b/.github/scripts/summarize-release-notes.mjs new file mode 100755 index 00000000..b0e2b576 --- /dev/null +++ b/.github/scripts/summarize-release-notes.mjs @@ -0,0 +1,209 @@ +#!/usr/bin/env node +// Turns a raw "- type(scope): subject" commit list (stdin) into bilingual, +// user-facing release notes via one OpenAI-compatible chat completion call. +// +// Env: RELEASE_NOTES_BASE_URL (e.g. https://host/v1), RELEASE_NOTES_API_KEY, +// RELEASE_NOTES_MODEL. Missing config or any LLM/validation failure falls +// back to a grouped plain-English commit list — this script never exits +// non-zero, so a notes problem can never block a release. +import fs from "fs"; +import { fileURLToPath } from "url"; + +const CJK_RE = /[一-鿿]/; +const REQUEST_TIMEOUT_MS = 60_000; +const MAX_HIGHLIGHTS = 10; +const CHANGELOG_CONTEXT_CHARS = 8000; + +export function buildPrompt(tag, commits, changelogExcerpt) { + const changelogSection = changelogExcerpt + ? `\n\nBackground from the project CHANGELOG (use only entries matching the commits above; it may describe unrelated work):\n${changelogExcerpt}` + : ""; + return `You are writing GitHub release notes for ${tag} of Codex Proxy, a desktop app (Electron) that lets users connect coding clients to their ChatGPT account. The audience is END USERS of the desktop app — not developers of this repo. + +Commits in this release: +${commits.join("\n")}${changelogSection} + +Write 3-8 highlight bullets describing what users will actually notice: new features, fixed problems (describe the symptom users experienced, not internal code details), and anything they should be aware of. Merge related commits into one bullet. Skip pure refactors, tests, and CI changes. Do not mention file names, internal module names, or commit-type prefixes. + +Respond with ONLY this JSON object, no other text: +{"highlights_zh": ["3-8 bullets in natural Chinese"], "highlights_en": ["the same bullets in natural English, same order"]}`; +} + +export function parseHighlights(raw) { + if (typeof raw !== "string") return null; + const unfenced = raw.replace(/```(?:json)?/gi, ""); + const start = unfenced.indexOf("{"); + const end = unfenced.lastIndexOf("}"); + if (start === -1 || end <= start) return null; + let parsed; + try { + parsed = JSON.parse(unfenced.slice(start, end + 1)); + } catch { + return null; + } + const zh = parsed?.highlights_zh; + const en = parsed?.highlights_en; + // Single-line + length-capped: commit subjects reach the LLM verbatim, so a + // prompt-injected response must not be able to smuggle extra markdown + // structure (headings, fake sections) into the published notes. + const isStringList = (v) => + Array.isArray(v) && + v.length > 0 && + v.length <= MAX_HIGHLIGHTS && + v.every((s) => typeof s === "string" && s.trim() !== "" && !s.includes("\n") && s.length <= 300); + if (!isStringList(zh) || !isStringList(en)) return null; + if (!CJK_RE.test(zh.join(""))) return null; + return { zh, en }; +} + +// A commit subject containing literal HTML (e.g. "") must not be +// able to break out of the details block in the published release body. +function escapeHtml(line) { + return line.replace(/&/g, "&").replace(//g, ">"); +} + +export function renderNotes(highlights, commitLines) { + const zh = highlights.zh.map((s) => `- ${s.trim()}`).join("\n"); + const en = highlights.en.map((s) => `- ${s.trim()}`).join("\n"); + return [ + "## ✨ 本次更新", + "", + zh, + "", + "## What's New", + "", + en, + "", + "
", + "完整提交记录 / Full commit list", + "", + commitLines.map(escapeHtml).join("\n"), + "", + "
", + ].join("\n"); +} + +const TYPE_GROUPS = [ + ["feat", "### Features"], + ["fix", "### Fixes"], + ["perf", "### Performance"], +]; + +export function renderFallback(commitLines) { + const groups = new Map(TYPE_GROUPS.map(([, title]) => [title, []])); + const other = []; + for (const line of commitLines) { + const subject = line.replace(/^- /, ""); + const match = subject.match(/^([a-z]+)(?:\([^)]*\))?!?:\s*(.*)$/i); + const entry = `- ${match ? match[2] : subject}`; + const group = match ? TYPE_GROUPS.find(([type]) => type === match[1].toLowerCase()) : undefined; + if (group) { + groups.get(group[1]).push(entry); + } else { + other.push(entry); + } + } + const sections = []; + for (const [, title] of TYPE_GROUPS) { + const entries = groups.get(title); + if (entries.length > 0) sections.push(`${title}\n\n${entries.join("\n")}`); + } + if (other.length > 0) sections.push(`### Other\n\n${other.join("\n")}`); + return sections.join("\n\n"); +} + +export async function callLLM({ baseUrl, apiKey, model, prompt, fetchImpl = fetch }) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const response = await fetchImpl(`${baseUrl.replace(/\/$/, "")}/chat/completions`, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model, + messages: [{ role: "user", content: prompt }], + temperature: 0.2, + stream: false, + }), + signal: controller.signal, + }); + if (!response.ok) throw new Error(`LLM endpoint returned ${response.status}`); + const data = await response.json(); + return data?.choices?.[0]?.message?.content ?? null; + } finally { + clearTimeout(timer); + } +} + +function readChangelogExcerpt() { + try { + return fs.readFileSync("CHANGELOG.md", "utf-8").slice(0, CHANGELOG_CONTEXT_CHARS); + } catch { + return ""; + } +} + +export async function generateNotes({ tag, input, env, fetchImpl = fetch, changelogExcerpt = "" }) { + const lines = input.split("\n").filter((l) => l.trim() !== ""); + const commitLines = lines.filter((l) => l.startsWith("- ")); + if (commitLines.length === 0) { + // "Initial release" / "Bug fixes and improvements" style passthrough + return input.trim(); + } + + const baseUrl = env.RELEASE_NOTES_BASE_URL?.trim(); + const apiKey = env.RELEASE_NOTES_API_KEY?.trim(); + const model = env.RELEASE_NOTES_MODEL?.trim(); + if (baseUrl && apiKey && model) { + const prompt = buildPrompt(tag, commitLines, changelogExcerpt); + for (let attempt = 0; attempt < 2; attempt++) { + try { + const raw = await callLLM({ baseUrl, apiKey, model, prompt, fetchImpl }); + const highlights = parseHighlights(raw); + if (highlights) return renderNotes(highlights, commitLines); + console.error(`[release-notes] attempt ${attempt + 1}: LLM output failed validation`); + } catch (error) { + console.error(`[release-notes] attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`); + } + } + console.error("[release-notes] falling back to grouped English commit list"); + } else { + console.error("[release-notes] LLM env not configured, using grouped English commit list"); + } + return renderFallback(commitLines); +} + +function isMainModule() { + try { + return Boolean(process.argv[1]) && fileURLToPath(import.meta.url) === fs.realpathSync(process.argv[1]); + } catch { + return false; + } +} + +if (isMainModule()) { + // Read stdin exactly once: fd 0 is at EOF after the first read, so the + // fatal fallback below must reuse this capture instead of re-reading. + let capturedInput = ""; + try { + capturedInput = fs.readFileSync(0, "utf-8"); + } catch { + capturedInput = ""; + } + const tag = process.argv[2] ?? "unreleased"; + generateNotes({ + tag, + input: capturedInput, + env: process.env, + changelogExcerpt: readChangelogExcerpt(), + }) + .then((notes) => console.log(notes)) + .catch((error) => { + // Last-resort guard: emit the captured input rather than blocking the release. + console.error(`[release-notes] fatal: ${error instanceof Error ? error.message : error}`); + console.log(capturedInput.trim() || "Bug fixes and improvements"); + }); +} diff --git a/.github/scripts/translate-notes.js b/.github/scripts/translate-notes.js deleted file mode 100644 index 3eb80199..00000000 --- a/.github/scripts/translate-notes.js +++ /dev/null @@ -1,552 +0,0 @@ -#!/usr/bin/env node -import fs from 'fs'; - -const typeMap = { - fix: '修复', - feat: '新增', - refactor: '重构', - test: '测试', - perf: '性能优化', - style: '格式调整', - ci: 'CI/CD', - docs: '文档', - chore: '例行维护', - build: '构建', -}; - -const scopeMap = { - translation: '翻译', - messages: '消息', - 'api-keys': 'API密钥', - docker: 'Docker', - tls: 'TLS', - responses: '响应', - cors: 'CORS', - security: '安全', - auth: '鉴权', - quota: '配额', - server: '服务端', - electron: 'Electron', - scripts: '脚本', - build: '构建', - release: '发布', - smoke: '冒烟测试', - cache: '缓存', - proxy: '代理', - diag: '诊断', - web: '前端', - version: '版本', -}; - -const vocabulary = [ - ['bug fixes and improvements', '缺陷修复与体验改进'], - ['bug fix and improvements', '缺陷修复与体验改进'], - ['bug fixes', '缺陷修复'], - ['bug fix', '缺陷修复'], - ['initial release', '初始版本发布'], - ['client-side', '客户端侧'], - ['corporate ssl inspection', '企业级 SSL 检查'], - ['os certificate store', '操作系统证书库'], - ['model-suffix', '模型后缀'], - ['model suffix', '模型后缀'], - ['response streaming', '响应流式化'], - ['parallel tool_use blocks', '并行工具调用块'], - ['parallel tool use', '并行工具调用'], - ['wire protocol', '传输协议'], - ['custom native api key', '自定义原生 API 密钥'], - ['api key', 'API 密钥'], - ['api keys', 'API 密钥'], - ['count tokens', '计词/统计 Token'], - ['token count', 'Token 计数'], - ['count token', '计词/统计 Token'], - ['update checker', '更新检查器'], - ['role tests', '角色测试'], - ['line endings', '行尾换行符'], - ['message roles', '消息角色'], - ['uname -m', 'uname -m'], - ['entrypoint', '入口脚本'], - ['auto-detect', '自动检测'], - ['auto detect', '自动检测'], - ['backfill output', '回填输出'], - ['middleware handler', '中间件处理器'], - ['middleware', '中间件'], - ['environment variable', '环境变量'], - ['server config', '服务端配置'], - ['harden affinity', '加固会话亲和性'], - ['harden dev promotion', '加固开发合并'], - ['code audit', '代码审计'], - ['critical/high defects', '严重/高危缺陷'], - ['critical defects', '严重缺陷'], - ['high defects', '高危缺陷'], - ['credit balance', '额度余额'], - ['pool overview', '账号池概览'], - ['billing-header', '计费请求头'], - ['billing header', '计费请求头'], - ['rotation variants', '轮转变体'], - ['error logs', '错误日志'], - ['error log', '错误日志'], - ['anthropic defaults', 'Anthropic 默认值'], - ['input_tokens inflation', '输入 Token 膨胀'], - ['input token inflation', '输入 Token 膨胀'], - ['messages translation', '消息翻译'], - ['host binding', '主机绑定'], - ['loopback origin', '本地回环源'], - ['loopback origins', '本地回环源'], - ['loopback', '本地回环'], - ['read pages', 'Read 工具页面'], - ['read tool', 'Read 工具'], - ['manual refresh', '手动刷新'], - ['refresh button', '刷新按钮'], - ['preflight requests', '预检请求'], - ['preflight request', '预检请求'], - ['embeddings support', '向量嵌入支持'], - ['embedding support', '向量嵌入支持'], - ['runtime api keys', '运行时 API 密钥'], - ['runtime api key', '运行时 API 密钥'], - ['cockpit account transfer', 'Cockpit 账号转移'], - ['compatibility', '兼容性'], - ['egress details', '出口详情'], - ['client replays', '客户端重放'], - ['client replay', '客户端重放'], - ['resume-not-applicable', '恢复不适用'], - ['missing_tool_calls', '缺失工具调用'], - ['missing-tool-call', '缺失工具调用'], - ['replay guard', '重放防护'], - ['poisoned cookie', '污染的 Cookie'], - ['cloudflare cookie', 'Cloudflare Cookie'], - ['function call ids', '函数调用 ID'], - ['function call id', '函数调用 ID'], - ['stable notes', '稳定版日志'], - ['dev promotion history', '开发合并历史'], - ['dev promotion', '开发合并'], - ['packaged smoke timeout', '打包打包冒烟超时'], - ['smoke timeout', '冒烟测试超时'], - ['smoke test', '冒烟测试'], - ['model alias', '模型别名'], - ['model aliases', '模型别名'], - ['stream fixes', '流式修复'], - ['lockfile sources', 'Lockfile 源'], - ['official npm registry', '官方 NPM 源'], - ['fingerprint state', '指纹状态'], - ['fingerprint yaml', '指纹 yaml'], - ['request diagnostics', '请求诊断'], - ['diagnostics logging', '诊断日志记录'], - ['diagnostics log', '诊断日志'], - ['usage logging', '使用日志记录'], - ['usage log', '使用日志'], - ['package boundary', '包边界'], - ['non-streaming', '非流式'], - ['success release', '成功发布'], - ['codex api error', 'Codex API 错误'], - ['failure handling', '失败处理'], - ['exhaustion handling', '耗尽处理'], - ['error planning', '错误规划'], - ['affinity recording', '亲和性记录'], - ['premature close', '提前关闭'], - ['empty response retry', '空响应重试'], - ['retry transition', '重试状态过渡'], - ['retry recovery', '重试恢复'], - ['fallback account retry', '备用账号重试'], - ['implicit resume lifecycle', '隐式恢复生命周期'], - ['implicit resume', '隐式恢复'], - ['session context', '会话上下文'], - ['upstream attempt', '上游尝试'], - ['request preparation', '请求准备'], - ['request state', '请求状态'], - ['fallback retry planning', '备用重试规划'], - ['stream response', '流式响应'], - ['stream tracing', '流式追踪'], - ['egress logging', '出口日志'], - ['build scripts', '构建脚本'], - ['build script', '构建脚本'], - ['debug dump', '调试转储'], - ['websocket context', 'WebSocket 上下文'], - ['stagger helper', '交错发送助手'], - ['rate-limit application', '速率限制应用'], - ['error response formatting', '错误响应格式化'], - ['streaming handler', '流式处理器'], - ['session helpers', '会话助手'], - ['session helper', '会话助手'], - ['direct request handler', '直连请求处理器'], - ['direct request', '直连请求'], - ['shared types', '共享类型'], - ['format adapters', '格式适配器'], - ['format adapter', '格式适配器'], - ['proxy handlers', '代理处理器'], - ['proxy handler', '代理处理器'], - ['stream-close events', '流关闭事件'], - ['stream-close event', '流关闭事件'], - ['audit log', '审计日志'], - ['chat requests', '聊天请求'], - ['chat request', '聊天请求'], - ['mobile logs overflow', '移动端日志溢出'], - ['dashboard design tokens', '仪表盘设计 Token'], - ['dashboard', '仪表盘'], - ['corrupt accounts.json', '损坏的 accounts.json'], - ['corrupt accounts', '损坏的账号'], - ['quarantine', '隔离'], - ['api routes', 'API 路由'], - ['api route', 'API 路由'], - ['cors middleware', 'CORS 中间件'], - ['implicit resume chains', '隐式恢复链'], - ['implicit resume chain', '隐式恢复链'], - ['rate_limit_until', 'rate_limit_until'], - ['cachedquota', '缓存配额'], - ['single truth', '单一数据源'], - ['usage history snapshots', '使用历史快照'], - ['usage history snapshot', '使用历史快照'], - ['debug-dump', '调试转储'], - ['upstream request', '上游请求'], - ['upstream response', '上游响应'], - ['bind errors', '绑定错误'], - ['bind error', '绑定错误'], - ['startserver', '启动服务端'], - ['autoupdate', '自动更新'], - ['update popup', '更新弹窗'], - ['renderer error capture', '渲染器错误捕获'], - ['errors tab', '错误选项卡'], - ['header badge', '头部徽章'], - ['local error-log', '本地错误日志'], - ['local error log', '本地错误日志'], - ['uncaught capture', '未捕获捕获'], - ['admin api', '管理端 API'], - ['bundled ws runtime', '打包的 ws 运行时'], - ['npm registry', 'NPM 源'], - ['mac x64 packaged smoke', 'Mac x64 打包冒烟测试'], - ['serve() bind race', 'serve() 绑定竞争'], - ['draft release', 'Release 草稿'], - ['manual upload step', '手动上传步骤'], - ['manual upload', '手动上传'], - ['smoke timeout', '冒烟超时'], - ['es-build', 'esbuild'], - ['esbuild', 'esbuild'], - ['prepare pack', '准备打包'], - ['auto-updater', '自动更新器'], - ['auto updater', '自动更新器'], - ['prompt cache key', '提示词缓存键'], - ['prompt cache', '提示词缓存'], - ['stream failure events', '流失败事件'], - ['stream failure event', '流失败事件'], - ['premature response stream', '提前结束的响应流'], - ['response stream', '响应流'], - ['reset windows', '重置窗口'], - ['reset window', '重置窗口'], - ['quota cache', '配额缓存'], - ['model suffix service tier', '模型后缀服务层'], - ['model suffix', '模型后缀'], - ['update status settings', '更新状态设置'], - ['update status', '更新状态'], - ['bridge concurrency', '桥接并发'], - ['agent bridge', '智能体桥接'], - ['unlimited usage history', '无限使用历史'], - ['usage history', '使用历史'], - ['update popup preference', '更新弹窗偏好设置'], - ['popup preference', '弹窗偏好设置'], - ['claude code model aliases', 'Claude Code 模型别名'], - ['claude code', 'Claude Code'], - ['model alias', '模型别名'], - ['model aliases', '模型别名'], - ['official codex app server bridge', '官方 Codex App 服务端桥接'], - ['codex app server bridge', 'Codex App 服务端桥接'], - ['codex app server', 'Codex App 服务端'], - ['token metadata', 'Token 元数据'], - ['quota skip', '配额跳过'], - ['api key routes', 'API 密钥路由'], - ['api key route', 'API 密钥路由'], - ['global proxy', '全局代理'], - ['ws fallback', 'WS 回退'], - ['websocket fallback', 'WebSocket 回退'], - ['codex review quota plumbing', 'Codex Review 配额管道'], - ['codex review quota', 'Codex Review 配额'], - ['reverse mismatch', '反向失配'], - ['surface 4xx errors', '展现 4xx 错误'], - ['surface 4xx error', '展现 4xx 错误'], - ['openai function tool strict', 'OpenAI 函数工具 strict 模式'], - ['function tool strict', '函数工具 strict 模式'], - ['function tool', '函数工具'], - ['upstream fetch requests', '上游拉取请求'], - ['upstream fetch request', '上游拉取请求'], - ['tee stdout/stderr', '双流输出 stdout/stderr'], - ['websocket connection pool', 'WebSocket 连接池'], - ['connection pool', '连接池'], - ['prompt-cache hit-rate', '提示词缓存命中率'], - ['prompt cache hit-rate', '提示词缓存命中率'], - ['prompt cache hit rate', '提示词缓存命中率'], - ['hit-rate jitter', '命中率抖动'], - ['hit rate jitter', '命中率抖动'], - ['range hit-rate card', '区间命中率卡片'], - ['chart panel', '图表面板'], - ['5min granularity', '5分钟粒度'], - ['proxy diag logs', '代理诊断日志'], - ['proxy diag log', '代理诊断日志'], - ['self-update', '自更新'], - ['dirty tree', '脏工作树'], - ['non-master branch', '非 master 分支'], - ['empty pages', '空页面'], - ['read tool', 'Read 工具'], - ['effectiveconversationid', '有效会话 ID'], - ['effective conversation id', '有效会话 ID'], - ['effective conversation', '有效会话'], - ['chat/gemini routes', 'Chat/Gemini 路由'], - ['chat/gemini route', 'Chat/Gemini 路由'], - ['ollama context metadata', 'Ollama 上下文元数据'], - ['context metadata', '上下文元数据'], - ['docker publish', 'Docker 发布'], - ['latest stable tag', '最新稳定版 Tag'], - ['stable tag', '稳定版 Tag'], - ['stale contributor', '过期的贡献者'], - ['checkout', '检出'], - ['install', '安装'], - ['stale', '过期的'], - ['clean', '清理'], - ['cleanup', '清理'], - ['normalize', '规范化'], - ['preserve', '保留'], - ['auto-detect', '自动检测'], - ['detect', '检测'], - ['backfill', '回填'], - ['propagate', '传递'], - ['harden', '加固'], - ['readiness', '就绪度'], - ['surface', '展示'], - ['honor', '遵循'], - ['resolve', '解决'], - ['sanitize', '消毒/净化'], - ['refresh', '刷新'], - ['preflight', '预检'], - ['embeddings', '向量嵌入'], - ['embedding', '向量嵌入'], - ['transfer', '转移'], - ['egress', '出口'], - ['replays', '重放'], - ['replay', '重放'], - ['poisoned', '被污染的'], - ['fingerprint', '指纹'], - ['diagnostics', '诊断'], - ['boundary', '边界'], - ['collect', '收集'], - ['exhaustion', '配额耗尽'], - ['affinity', '亲和性'], - ['premature', '提前的'], - ['fallback', '回退'], - ['resume', '恢复'], - ['lifecycle', '生命周期'], - ['stagger', '交错'], - ['rate-limit', '速率限制'], - ['streaming', '流式'], - ['stream', '流'], - ['direct', '直连'], - ['setup', '初始化设置'], - ['theme', '主题'], - ['quarantine', '隔离'], - ['loopback', '本地回环'], - ['isolated', '隔离的'], - ['reconnect', '重新连接'], - ['allow', '允许'], - ['popup', '弹窗'], - ['aliases', '别名'], - ['alias', '别名'], - ['bridge', '网桥'], - ['plumbing', '管道'], - ['mismatch', '不匹配'], - ['strict', '严格模式'], - ['granularity', '粒度'], - ['contributor', '贡献者'], - ['checker', '检查器'], - ['smoke', '冒烟测试'], - ['manifest', '清单'], - ['probe', '探测'], - ['draft', '草稿'], - ['upload', '上传'], - ['asset', '资源'], - ['assets', '资源'], - ['badge', '徽章'], - ['observability', '可观测性'], - ['uncaught', '未捕获的'], - ['admin', '管理员'], - ['subprocess', '子进程'], - ['bundle', '打包'], - ['contract', '契约'], - ['exhausted', '已耗尽'], - ['warnings', '警告'], - ['warning', '警告'], - ['events', '事件'], - ['event', '事件'], - ['scheduler', '调度器'], - ['schedule', '调度'], - ['active', '活跃的'], - ['expired', '已过期的'], - ['destroy', '销毁'], - ['diagnose', '诊断'], - ['preference', '偏好'], - ['concurrency', '并发'], - ['recovery', '恢复'], - ['transfer', '转移'], - ['warning', '警告'], - ['registry', '注册表'], - ['pool', '账号池'], - ['rotation', '轮转'], - ['strategy', '策略'], - ['origin', '源'], - ['originator', '发起者'], - ['ip', 'IP 地址'], -]; - -function translateText(text) { - let result = text; - - // 1. Replace phrases and words - for (const [english, chinese] of vocabulary) { - const escaped = english.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); - const regex = new RegExp(`\\b${escaped}\\b`, 'gi'); - result = result.replace(regex, chinese); - } - - // 2. Translate leading verbs - const verbs = [ - [/^add\b/i, '添加'], - [/^adds\b/i, '添加'], - [/^remove\b/i, '移除'], - [/^removes\b/i, '移除'], - [/^delete\b/i, '删除'], - [/^deletes\b/i, '删除'], - [/^update\b/i, '更新'], - [/^updates\b/i, '更新'], - [/^fix\b/i, '修复'], - [/^fixes\b/i, '修复'], - [/^implement\b/i, '实现'], - [/^implements\b/i, '实现'], - [/^support\b/i, '支持'], - [/^supports\b/i, '支持'], - [/^improve\b/i, '改进'], - [/^improves\b/i, '改进'], - [/^harden\b/i, '加固'], - [/^hardens\b/i, '加固'], - [/^prevent\b/i, '防止'], - [/^prevents\b/i, '防止'], - [/^avoid\b/i, '避免'], - [/^avoids\b/i, '避免'], - [/^handle\b/i, '处理'], - [/^handles\b/i, '处理'], - [/^allow\b/i, '允许'], - [/^allows\b/i, '允许'], - [/^clean\b/i, '清理'], - [/^cleanup\b/i, '清理'], - [/^normalize\b/i, '规范化'], - [/^normalizes\b/i, '规范化'], - [/^preserve\b/i, '保留'], - [/^preserves\b/i, '保留'], - [/^surface\b/i, '展示'], - [/^surfaces\b/i, '展示'], - [/^honor\b/i, '遵循'], - [/^honors\b/i, '遵循'], - [/^secure\b/i, '安全保护'], - [/^secures\b/i, '安全保护'], - [/^resolve\b/i, '解决'], - [/^resolves\b/i, '解决'], - [/^correct\b/i, '修正'], - [/^corrects\b/i, '修正'], - [/^propagate\b/i, '传递'], - [/^propagates\b/i, '传递'], - [/^optimize\b/i, '优化'], - [/^optimizes\b/i, '优化'], - [/^ignore\b/i, '忽略'], - [/^ignores\b/i, '忽略'], - [/^check\b/i, '检查'], - [/^checks\b/i, '检查'], - [/^align\b/i, '对齐'], - [/^aligns\b/i, '对齐'], - [/^clear\b/i, '清除'], - [/^clears\b/i, '清除'], - [/^tolerate\b/i, '兼容/容忍'], - [/^tolerates\b/i, '兼容/容忍'], - [/^limit\b/i, '限制'], - [/^limits\b/i, '限制'], - [/^setup\b/i, '设置/初始化'], - [/^prepare\b/i, '准备'], - [/^prepares\b/i, '准备'], - [/^refactor\b/i, '重构'], - [/^refactors\b/i, '重构'], - [/^extend\b/i, '扩展'], - [/^extends\b/i, '扩展'], - [/^retry\b/i, '重试'], - [/^retries\b/i, '重试'], - [/^emit\b/i, '发送/触发'], - [/^emits\b/i, '发送/触发'], - [/^pin\b/i, '固定'], - [/^pins\b/i, '固定'], - ]; - - for (const [verbRegex, chineseVerb] of verbs) { - if (verbRegex.test(result)) { - result = result.replace(verbRegex, chineseVerb); - break; - } - } - - // 3. Common prepositions - result = result - .replace(/\bfor\b/gi, '用于') - .replace(/\bto\b/gi, '到') - .replace(/\bon\b/gi, '在') - .replace(/\bwith\b/gi, '配合') - .replace(/\bvia\b/gi, '通过') - .replace(/\bby\b/gi, '通过') - .replace(/\bin\b/gi, '在') - .replace(/\bfrom\b/gi, '从') - .replace(/\band\b/gi, '和'); - - return result; -} - -function translateCommit(line) { - // Strip the leading '- ' first - const hasBullet = line.startsWith('- '); - const content = hasBullet ? line.substring(2) : line; - - const match = content.match(/^([a-zA-Z0-9_-]+)(?:\(([^)]+)\))?:\s*(.*)$/); - if (!match) { - return hasBullet ? `- ${translateText(content)}` : translateText(content); - } - - const type = match[1]; - const scope = match[2]; - const subject = match[3]; - - const translatedType = typeMap[type.toLowerCase()] || type; - const translatedScope = scope ? (scopeMap[scope.toLowerCase()] || scope) : ''; - const translatedSubject = translateText(subject); - - let translatedLine = ''; - if (translatedScope) { - translatedLine = `${translatedType}(${translatedScope}):${translatedSubject}`; - } else { - translatedLine = `${translatedType}:${translatedSubject}`; - } - - return hasBullet ? `- ${translatedLine}` : translatedLine; -} - -const input = fs.readFileSync(0, 'utf-8').trim(); -if (!input) { - process.exit(0); -} - -// Check if it's a single phrase like "Bug fixes and improvements" or "Initial release" -const lines = input.split('\n'); -if (lines.length === 1 && !lines[0].startsWith('- ')) { - const translated = translateText(lines[0]); - console.log(`## 🌐 English / 英文版\n${lines[0]}\n\n## 🇨🇳 中文版 (翻译)\n${translated}`); - process.exit(0); -} - -// Process multiple lines -const englishLines = []; -const chineseLines = []; - -for (const line of lines) { - englishLines.push(line); - chineseLines.push(translateCommit(line)); -} - -console.log(`## 🌐 English / 英文版\n${englishLines.join('\n')}\n\n## 🇨🇳 中文版 (翻译)\n${chineseLines.join('\n')}`); diff --git a/.github/workflows/bump-electron.yml b/.github/workflows/bump-electron.yml index 61292b4a..f4a7865d 100644 --- a/.github/workflows/bump-electron.yml +++ b/.github/workflows/bump-electron.yml @@ -139,4 +139,7 @@ jobs: if: steps.bump.outputs.new_tag env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh workflow run docker-publish.yml --ref master + NEW_TAG: ${{ steps.bump.outputs.new_tag }} + # tag input → docker-publish builds FROM the tag and also publishes + # the ghcr.io vX.Y.Z version image (branch pushes are latest-only). + run: gh workflow run docker-publish.yml --ref master -f tag="$NEW_TAG" diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index a93bc02b..1269191b 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -4,6 +4,11 @@ on: push: branches: [master] workflow_dispatch: + inputs: + tag: + description: "Stable tag to publish as a version image (e.g. v2.0.73). Omit for latest-only." + required: false + type: string concurrency: group: docker-publish @@ -19,30 +24,53 @@ jobs: steps: - uses: actions/checkout@v4 with: - fetch-tags: true + # Version images must be built from the tagged source, not whatever + # master currently points at. Branch pushes (e.g. the 14:00 UTC + # promote) only refresh `latest` + a sha tag — the old behavior + # ("max(package.json, latest stable tag)") rebuilt vX.Y.Z from + # newer-than-tag code and silently clobbered the version image. + ref: ${{ inputs.tag || github.ref }} - - name: Read version + - name: Resolve image tags id: version + env: + TAG_INPUT: ${{ inputs.tag }} run: | - PKG_VERSION=$(node -p "require('./package.json').version") + # Dispatch inputs are arbitrary strings; refuse anything that is not + # a plain semver tag. bash [[ =~ ]] anchors the WHOLE string (grep + # would match per-line and let a multiline payload through into the + # GITHUB_OUTPUT heredoc below). + if [ -n "$TAG_INPUT" ] && ! [[ "$TAG_INPUT" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?$ ]]; then + echo "::error::invalid tag input" + exit 1 + fi - # Find latest stable tag (exclude prerelease like -beta.xxx) - LATEST_STABLE=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1 | sed 's/^v//' || echo "0.0.0") + SHORT_SHA=$(git rev-parse --short=7 HEAD) - # Pick the greater of package.json vs latest stable tag - FINAL=$(node -e " - const [a, b] = ['$PKG_VERSION', '$LATEST_STABLE']; - const cmp = (x, y) => { - const pa = x.split('.').map(Number); - const pb = y.split('.').map(Number); - for (let i = 0; i < 3; i++) { - if (pa[i] !== pb[i]) return pa[i] - pb[i]; - } - return 0; - }; - console.log(cmp(a, b) >= 0 ? a : b); - ") - echo "version=$FINAL" >> "$GITHUB_OUTPUT" + # Re-tagging `latest` is only safe when we're building the current + # master tip. A manual dispatch of an OLD tag (re-publishing a + # version image) must not silently move `latest` backwards. + PUSH_LATEST=true + if [ -n "$TAG_INPUT" ]; then + git fetch origin master --depth=1 >/dev/null 2>&1 || true + MASTER_SHA=$(git rev-parse FETCH_HEAD 2>/dev/null || echo "") + if [ "$(git rev-parse HEAD)" != "$MASTER_SHA" ]; then + echo "tag $TAG_INPUT is not the master tip, skipping latest" + PUSH_LATEST=false + fi + fi + + { + echo "tags<> "$GITHUB_OUTPUT" - uses: docker/setup-qemu-action@v3 @@ -58,9 +86,7 @@ jobs: id: meta with: images: ghcr.io/${{ github.repository }} - tags: | - type=raw,value=latest - type=raw,value=v${{ steps.version.outputs.version }} + tags: ${{ steps.version.outputs.tags }} - uses: docker/build-push-action@v6 with: diff --git a/.github/workflows/promote-dev-to-master.yml b/.github/workflows/promote-dev-to-master.yml index 2f48d7d6..6f52d6fa 100644 --- a/.github/workflows/promote-dev-to-master.yml +++ b/.github/workflows/promote-dev-to-master.yml @@ -70,58 +70,72 @@ jobs: exit 1 fi - - name: Check soak (>= 24h since latest dev commit) + - name: Select promotion candidates (soak >= 24h, starvation-proof) id: soak if: steps.ff.outputs.ok == 'true' + env: + FORCE: ${{ inputs.force_skip_soak }} run: | - DEV_TS=$(git log -1 origin/dev --format=%ct) - NOW=$(date +%s) - AGE=$(( NOW - DEV_TS )) - MIN_AGE=86400 - echo "dev HEAD age: ${AGE}s (need >= ${MIN_AGE}s)" - if [ "${{ inputs.force_skip_soak }}" = "true" ]; then - echo "::notice::Soak skipped via workflow_dispatch input (age=${AGE}s)" - echo "ok=true" >> "$GITHUB_OUTPUT" - elif [ "$AGE" -lt "$MIN_AGE" ]; then - REMAIN=$(( MIN_AGE - AGE )) - echo "::notice::Soak: ${REMAIN}s remaining before dev is eligible for promotion" + # Old rule required dev HEAD itself to be >= 24h old; during active + # weeks every push reset the clock and master starved. The script + # instead emits the newest first-parent dev commits older than 24h + # (newest first) — fresh commits keep soaking and ride tomorrow. + CANDIDATES=$(bash .github/scripts/select-promote-candidate.sh) + if [ -z "$CANDIDATES" ]; then + echo "::notice::Soak: no dev commit is older than the 24h window yet" echo "ok=false" >> "$GITHUB_OUTPUT" else + echo "Candidates (newest eligible first):" + echo "$CANDIDATES" echo "ok=true" >> "$GITHUB_OUTPUT" + { + echo "candidates<> "$GITHUB_OUTPUT" fi - - name: Check CI status on dev HEAD + - name: Pick first CI-green candidate id: ci if: steps.soak.outputs.ok == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CANDIDATES: ${{ steps.soak.outputs.candidates }} run: | - DEV_SHA=$(git rev-parse origin/dev) - echo "Checking check-runs for $DEV_SHA" - # Exclude this workflow's own checks (promote-dev) and bump workflows from the gate. # (?i) makes the regex case-insensitive so we catch "promote"/"Promote", "bump-beta"/etc. - STATUS=$(gh api "repos/${{ github.repository }}/commits/${DEV_SHA}/check-runs" \ - --jq ' - [.check_runs[] - | select(.name | test("(?i)promote|bump") | not) - ] as $relevant - | if ($relevant | length) == 0 - then "missing-checks" - else - if all($relevant[]; .conclusion == "success" or .conclusion == "skipped") - then "green" - else "not-green" + PICKED="" + while read -r SHA; do + [ -z "$SHA" ] && continue + # `|| STATUS=api-error`: a transient gh/API failure must skip the + # candidate (like missing-checks), not abort the job — an abort + # here would fire the ff-failure issue/webhook with a wrong cause. + STATUS=$(gh api "repos/${{ github.repository }}/commits/${SHA}/check-runs" \ + --jq ' + [.check_runs[] + | select(.name | test("(?i)promote|bump") | not) + ] as $relevant + | if ($relevant | length) == 0 + then "missing-checks" + else + if all($relevant[]; .conclusion == "success" or .conclusion == "skipped") + then "green" + else "not-green" + end end - end - ') - - echo "status=$STATUS" - if [ "$STATUS" = "green" ]; then + ') || STATUS="api-error" + echo "$SHA → $STATUS" + if [ "$STATUS" = "green" ]; then + PICKED="$SHA" + break + fi + done <<< "$CANDIDATES" + + if [ -n "$PICKED" ]; then echo "ok=true" >> "$GITHUB_OUTPUT" - echo "dev_sha=$DEV_SHA" >> "$GITHUB_OUTPUT" + echo "dev_sha=$PICKED" >> "$GITHUB_OUTPUT" else - echo "::notice::CI on dev is not green (status=$STATUS), skipping promotion" + echo "::notice::No soaked candidate has green CI, skipping promotion" echo "ok=false" >> "$GITHUB_OUTPUT" fi @@ -135,6 +149,14 @@ jobs: git push origin "${DEV_SHA}:refs/heads/master" echo "::notice::Promoted dev ($DEV_SHA) to master. bump-electron.yml will pick it up on the next 16:00 UTC tick." + - name: Notify promotion + if: steps.ci.outputs.ok == 'true' + env: + NOTIFY_WEBHOOK_URL: ${{ secrets.NOTIFY_WEBHOOK_URL }} + run: | + bash .github/scripts/notify-webhook.sh \ + "🚀 Promoted dev (${{ steps.ci.outputs.dev_sha }}) to master — stable bump at 16:00 UTC" + - name: Trigger docker publish # GITHUB_TOKEN-driven pushes do not fire downstream workflows (anti-recursion # guard), so docker-publish.yml's `on: push: branches: [master]` would @@ -155,7 +177,21 @@ jobs: runs-on: ubuntu-latest permissions: issues: write + # job-level permissions reset everything else to none; checkout (for + # notify-webhook.sh) needs explicit read even on a public repo clone + # via the default token. + contents: read steps: + - name: Checkout (scripts only) + uses: actions/checkout@v4 + + - name: Notify promote failure + env: + NOTIFY_WEBHOOK_URL: ${{ secrets.NOTIFY_WEBHOOK_URL }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + bash .github/scripts/notify-webhook.sh "❌ promote dev→master job failed: $RUN_URL" + - name: Open tracking issue on promote failure env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a0ff8417..a900e0fc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,68 @@ permissions: contents: write jobs: + # Runs FIRST so the GitHub Release exists with real notes before any + # artifact upload. Previously notes were generated in the last job: upload + # steps created the release with `--notes ""`, and a notes-job failure left + # the body empty forever (exactly what beta users see in the update popup). + notes: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag || github.ref }} + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Fetch dev for stable release notes + run: git fetch origin dev:refs/remotes/origin/dev || true + + - name: Generate release notes and create release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ inputs.tag || github.ref_name }} + RELEASE_NOTES_BASE_URL: ${{ secrets.RELEASE_NOTES_BASE_URL }} + RELEASE_NOTES_API_KEY: ${{ secrets.RELEASE_NOTES_API_KEY }} + RELEASE_NOTES_MODEL: ${{ secrets.RELEASE_NOTES_MODEL }} + run: | + bash .github/scripts/generate-release-notes.sh "$TAG" > /tmp/release-notes.md + + # Tag like v1.2.3-beta.SHA → mark as prerelease (semver convention). + # Stable tags stay --latest=false until assets are uploaded: this + # release has zero assets for the whole build window, and stable + # update isolation relies on GitHub /releases/latest — clients must + # keep resolving the previous good release until the notify job + # flips --latest after all platforms pass smoke. + PRERELEASE_FLAG="" + LATEST_FLAG="" + case "$TAG" in + *-*) PRERELEASE_FLAG="--prerelease" ;; + *) LATEST_FLAG="--latest=false" ;; + esac + + PUBLISHED="" + for i in 1 2 3; do + if gh release edit "$TAG" --draft=false $PRERELEASE_FLAG $LATEST_FLAG --notes-file /tmp/release-notes.md; then + PUBLISHED=1; break + fi + if gh release create "$TAG" --title "$TAG" $PRERELEASE_FLAG $LATEST_FLAG --notes-file /tmp/release-notes.md; then + PUBLISHED=1; break + fi + echo "release edit/create failed (attempt $i/3), retrying..." + sleep $(( i * 15 )) + done + if [ -z "$PUBLISHED" ]; then + echo "::error::could not create or update release $TAG after 3 attempts" + exit 1 + fi + build: + needs: notes strategy: fail-fast: false matrix: @@ -42,7 +103,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: npm cache-dependency-path: | package-lock.json @@ -173,8 +234,9 @@ jobs: # to arrive needs to. `|| true` swallows the 422 that the # concurrent matrix jobs hit when a peer wins the race. IS_PRERELEASE="" - if [[ "$TAG" == *-* ]]; then IS_PRERELEASE="--prerelease"; fi - gh release create "$TAG" --title "$TAG" --notes "" --draft=false $IS_PRERELEASE 2>/dev/null || true + LATEST_FLAG="" + if [[ "$TAG" == *-* ]]; then IS_PRERELEASE="--prerelease"; else LATEST_FLAG="--latest=false"; fi + gh release create "$TAG" --title "$TAG" --notes "" --draft=false $IS_PRERELEASE $LATEST_FLAG 2>/dev/null || true cd packages/electron/release case "${{ matrix.platform }}" in mac) @@ -218,7 +280,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: npm cache-dependency-path: | package-lock.json @@ -309,12 +371,13 @@ jobs: - name: Upload x64 artifacts to release run: | TAG="${{ inputs.tag || github.ref_name }}" - # Same release-create safety net as the main build job — - # this job runs after `build` but if all 3 main matrix - # platforms failed smoke, the release wouldn't exist yet. + # Cheap backstop only: the `notes` job (which every build job + # depends on) already created the release with real notes, so + # this create normally no-ops with "already exists". IS_PRERELEASE="" - if [[ "$TAG" == *-* ]]; then IS_PRERELEASE="--prerelease"; fi - gh release create "$TAG" --title "$TAG" --notes "" --draft=false $IS_PRERELEASE 2>/dev/null || true + LATEST_FLAG="" + if [[ "$TAG" == *-* ]]; then IS_PRERELEASE="--prerelease"; else LATEST_FLAG="--latest=false"; fi + gh release create "$TAG" --title "$TAG" --notes "" --draft=false $IS_PRERELEASE $LATEST_FLAG 2>/dev/null || true cd packages/electron/release for f in *; do [ -f "$f" ] || continue @@ -348,44 +411,65 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - release: - needs: [build, build-mac-x64] + notify: + needs: [notes, build, build-mac-x64] runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/v') || inputs.tag - + if: always() && (startsWith(github.ref, 'refs/tags/v') || inputs.tag) steps: - - name: Checkout + - name: Checkout (scripts only) uses: actions/checkout@v4 with: ref: ${{ inputs.tag || github.ref }} - fetch-depth: 0 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: 20 - - - name: Fetch dev for stable release notes - run: git fetch origin dev:refs/remotes/origin/dev || true - - - name: Resolve tag - id: tag - run: | - TAG="${{ inputs.tag || github.ref_name }}" - echo "name=$TAG" >> "$GITHUB_OUTPUT" - - name: Generate release notes and publish + - name: Mark stable release as latest (assets are complete now) + id: flip + if: ${{ !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ inputs.tag || github.ref_name }} run: | - TAG="${{ steps.tag.outputs.name }}" - bash .github/scripts/generate-release-notes.sh "$TAG" > /tmp/release-notes.md - - # Tag like v1.2.3-beta.SHA → mark as prerelease (semver convention). - # Defensive: electron-builder normally does this, but the create fallback - # below would miss it when electron-builder's draft creation didn't fire. - PRERELEASE_FLAG="" - case "$TAG" in *-*) PRERELEASE_FLAG="--prerelease" ;; esac + case "$TAG" in *-*) echo "prerelease, never latest"; exit 0 ;; esac + # A flip failure makes a fully-built stable release invisible to the + # auto-updater (/releases/latest keeps pointing at the previous + # one) — retry, and let the webhook below report the failure. + for i in 1 2 3; do + if gh release edit "$TAG" --latest; then exit 0; fi + echo "marking latest failed (attempt $i/3), retrying..." + sleep 10 + done + echo "::error::failed to mark $TAG as latest after 3 attempts" + exit 1 - gh release edit "$TAG" --draft=false $PRERELEASE_FLAG --notes-file /tmp/release-notes.md 2>/dev/null || \ - gh release create "$TAG" --title "$TAG" $PRERELEASE_FLAG --notes-file /tmp/release-notes.md + - name: Delete asset-less release on build failure + if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ inputs.tag || github.ref_name }} + run: | + # The notes job publishes the release before any artifact exists. + # If every platform failed, a published zero-asset release would + # break beta clients (allow_prerelease resolves the NEWEST release + # and 404s on latest*.yml). Delete it — the tag survives, so a + # manual re-dispatch of release.yml can retry the same version. + COUNT=$(gh release view "$TAG" --json assets -q '.assets | length' 2>/dev/null || echo "") + if [ "$COUNT" = "0" ]; then + echo "deleting zero-asset release $TAG (tag kept)" + gh release delete "$TAG" -y || true + else + echo "release $TAG has ${COUNT:-unknown} asset(s), leaving it in place" + fi + + - name: Send webhook notification + if: always() + env: + NOTIFY_WEBHOOK_URL: ${{ secrets.NOTIFY_WEBHOOK_URL }} + TAG: ${{ inputs.tag || github.ref_name }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + RELEASE_URL: ${{ github.server_url }}/${{ github.repository }}/releases/tag/${{ inputs.tag || github.ref_name }} + run: | + if [ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || steps.flip.outcome == 'failure' }}" = "true" ]; then + MSG="❌ Release $TAG failed: $RUN_URL" + else + MSG="✅ Release $TAG published: $RELEASE_URL" + fi + bash .github/scripts/notify-webhook.sh "$MSG" diff --git a/CHANGELOG.md b/CHANGELOG.md index bae346a4..1fc5ffc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,8 @@ ### Added -- 支持 Release Notes 中英文双语自动生成:在 `generate-release-notes.sh` 脚本输出末尾通过管道并结合 Node.js 编写的 `translate-notes.js` 进行结构化翻译过滤,自动生成英文对照和中文翻译两部分。(`.github/scripts/translate-notes.js`、`.github/scripts/generate-release-notes.sh`、`tests/unit/ci/release-notes-script.test.ts`) +- Release Notes 改为 LLM 双语生成(替换并删除此前的逐词替换字典 `translate-notes.js`,机翻词盐根因):新增 `summarize-release-notes.mjs` 调 OpenAI-compatible endpoint(secrets:`RELEASE_NOTES_BASE_URL/API_KEY/MODEL`)按 Electron 用户视角输出「✨ 本次更新」中文亮点 + 英文对照 + 折叠完整 commit 清单;输出强校验(JSON 契约、必须含 CJK、条数上限),LLM 不可用/校验失败时回退按 type 分组的纯英文列表,脚本永不非零退出(notes 问题不阻塞发版);notes 过滤规则对齐 bump 的 `SKIP_RELEASE_PATTERN`(排除 chore/docs/ci/test/refactor/style);已用真实 gateway 连调 3 次验证双语产出(`.github/scripts/summarize-release-notes.mjs`、`.github/scripts/generate-release-notes.sh`、`tests/unit/ci/summarize-release-notes.test.ts`、`tests/unit/ci/release-notes-script.test.ts`) +- CI 发版通知 webhook:release 成功/失败、promote 成功/ff 前提破坏时 POST 纯文本到 `NOTIFY_WEBHOOK_URL` secret(ntfy 开箱可用);secret 未配置时静默跳过、永不阻塞流水线(`.github/scripts/notify-webhook.sh`、`.github/workflows/release.yml`、`.github/workflows/promote-dev-to-master.yml`) - 自定义 API Key 的 Upstream protocol 新增 Anthropic Messages / Gemini generateContent 选项,并支持对应 base URL、模型列表获取与底层转发(`shared/hooks/use-api-keys.ts`、`src/auth/api-key-model-cache.ts`、`src/proxy/adapter-factory.ts`、`web/src/components/ApiKeyManager.tsx`)。 - 修复并支持自定义 Anthropic & Gemini Upstream Base URL 与 ProxyPool 健康检查 URL(硬编码审计与修复):在 `config-schema.ts` 的 `providers.anthropic` 和 `providers.gemini` 中添加可选的 `base_url` 字段,且在 `tls` 中添加 `health_check_url`(默认为 `https://api.ipify.org?format=json`)。通过构造函数将配置传入 `AnthropicUpstream` 和 `GeminiUpstream` 并在请求中动态调用;修改 `ProxyPool` 的 `healthCheck` 获取动态的健康检查 URL。新增 `tests/unit/proxy/adapter-factory.test.ts` 与 `tests/unit/config-schema.test.ts` 相关测试以保障 TDD。(`src/config-schema.ts`、`src/proxy/anthropic-upstream.ts`、`src/proxy/gemini-upstream.ts` 、`src/proxy/adapter-factory.ts`、`src/index.ts`、`src/proxy/proxy-pool.ts`、`tests/unit/config-schema.test.ts`、`tests/unit/proxy/adapter-factory.test.ts`) - Web 前端测试接入 CI 门禁:`web/` 是独立 package(非 root workspace)、用自己的 vitest(jsdom),其组件测试此前不被 root `npm test` 收录、不在任何自动化里跑。新增 root `test:web` 脚本(`cd web && npx vitest run`)和 `ci-quality.yml` 独立 `frontend-tests` job(`cd web && npm ci` + vitest),把现存 7 个 web 测试文件 16 用例纳入 PR 门禁;并给 `web/vite.config.ts` 补 `preact/devtools` / `preact/debug` alias——`@preact/preset-vite` 会把这两个 import 注入 `shared/` 文件,干净 `npm ci`(CI)从 web/ 外解析不到会在 import 阶段直接炸(本地因根 node_modules 有 preact 兜底而测不出)(`package.json`、`.github/workflows/ci-quality.yml`、`web/vite.config.ts`) @@ -41,6 +42,9 @@ ### Fixed +- 修复 promote soak 饿死:旧规则要求 dev HEAD 本身 ≥24h,活跃开发期每个新 commit 重置时钟导致 master 长期不晋升;新增 `select-promote-candidate.sh` 改为晋升「最新的、已泡满 24h 的 dev first-parent commit」(新鲜提交留在 dev 继续 soak、次日跟进),CI 门禁逐候选回退找绿;`force_skip_soak` 仍直接取 dev HEAD(`.github/scripts/select-promote-candidate.sh`、`.github/workflows/promote-dev-to-master.yml`、`tests/unit/ci/select-promote-candidate.test.ts`) +- 修复 Docker 版本镜像被覆盖:`docker-publish.yml` 此前在 master 分支 push 时用 `max(package.json, 最新 stable tag)` 决定版本号,14:00 promote 后会用「晚于 tag 的代码」重打 `ghcr.io/...:vX.Y.Z` 镜像(镜像内容 ≠ git tag);现在分支 push 只打 `latest` + `sha-`,版本镜像仅由 `bump-electron.yml` dispatch 带 `tag` 输入、从 tag 源码构建(`.github/workflows/docker-publish.yml`、`.github/workflows/bump-electron.yml`) +- 修复 release 空 body 窗口:`release.yml` notes 生成从最后一个 job 提前为第一个 job,此前 upload 步骤先 `gh release create --notes ""`、notes job 挂掉则 release 永久空 body(更新弹窗里用户看到空白);stable release 创建时 `--latest=false`,全部平台 smoke 通过后才翻 `--latest`(构建窗口内 `/releases/latest` 始终指向上一个完整版本,auto-updater 不会撞到零资产 release);构建全挂时自动删除零资产 release(保留 tag 可重试),防止 beta 渠道解析到空版本;顺带 release/build job Node 版本统一到 22(与 ci-quality 一致)(`.github/workflows/release.yml`) - 修复自定义 API Key 原生 wire 的 provider/wire 一致性问题:后端拒绝 built-in provider 的无效 wire/baseUrl 组合,custom 模型缓存按 wire 隔离,Anthropic 模型发现改用 x-api-key,Anthropic 上游请求接入 fetch dispatcher,Embeddings 路由拒绝 custom Anthropic/Gemini wire,并在前端切换 custom wire 时清空旧模型列表(src/routes/api-keys.ts、src/auth/api-key-model-cache.ts、src/proxy/anthropic-upstream.ts、src/routes/embeddings.ts、web/src/components/ApiKeyManager.tsx)。 - 修复 Windows 本地缺少 POSIX shell 时 CI 脚本单测失败、Electron bundle 动态导入路径以及 `full-update` 子进程启动路径含空格的问题(`tests/unit/ci/*`、`packages/electron/__tests__/build.test.ts`、`scripts/build/full-update.ts`)。 diff --git a/tests/unit/ci/release-notes-script.test.ts b/tests/unit/ci/release-notes-script.test.ts index 68232bb7..227ac541 100644 --- a/tests/unit/ci/release-notes-script.test.ts +++ b/tests/unit/ci/release-notes-script.test.ts @@ -86,8 +86,8 @@ describeIfBash("generate-release-notes.sh bash behavior", () => { const notes = runNotes(cwd, "v1.0.1"); - expect(notes).toContain("- fix: direct stable fix (#1)"); - expect(notes).not.toContain("docs: update readme"); + expect(notes).toContain("direct stable fix (#1)"); + expect(notes).not.toContain("update readme"); }); it("falls back to dev history when a stable tag only contains a squash promotion", () => { @@ -96,7 +96,7 @@ describeIfBash("generate-release-notes.sh bash behavior", () => { writeText(cwd, "src/app.txt", "real fix\n"); commitAll(cwd, "fix: real user-facing fix (#10)"); writeText(cwd, "src/helper.txt", "cleanup\n"); - commitAll(cwd, "refactor: internal helper cleanup (#11)"); + commitAll(cwd, "feat: user-visible helper feature (#11)"); git(cwd, ["update-ref", "refs/remotes/origin/dev", "dev"]); git(cwd, ["checkout", "master"]); @@ -111,8 +111,8 @@ describeIfBash("generate-release-notes.sh bash behavior", () => { const notes = runNotes(cwd, "v1.0.1"); - expect(notes).toContain("- fix: real user-facing fix (#10)"); - expect(notes).toContain("- refactor: internal helper cleanup (#11)"); + expect(notes).toContain("real user-facing fix (#10)"); + expect(notes).toContain("user-visible helper feature (#11)"); expect(notes).not.toContain("promote dev release fixes"); }); @@ -139,11 +139,49 @@ describeIfBash("generate-release-notes.sh bash behavior", () => { const notes = runNotes(cwd, "v1.0.2-beta.1"); // The release notes should only contain the commits since the last release (v1.0.1) - expect(notes).toContain("- fix: critical v1.0.2 bugfix (#100)"); - expect(notes).not.toContain("- fix: intermediary stable fix (#50)"); + expect(notes).toContain("critical v1.0.2 bugfix (#100)"); + expect(notes).not.toContain("intermediary stable fix (#50)"); }); - it("translates the release notes to Chinese and generates bilingual output", () => { + it("filters refactor/test/style commits in line with the bump trigger filter", () => { + const cwd = createRepo(); + writeText(cwd, "src/app.txt", "fix\n"); + commitAll(cwd, "fix: user-facing fix (#1)"); + writeText(cwd, "src/r.txt", "r\n"); + commitAll(cwd, "refactor: internal cleanup (#2)"); + writeText(cwd, "src/t.txt", "t\n"); + commitAll(cwd, "test: add coverage (#3)"); + git(cwd, ["tag", "v1.0.1"]); + + const notes = runNotes(cwd, "v1.0.1"); + + expect(notes).toContain("user-facing fix (#1)"); + expect(notes).not.toContain("internal cleanup"); + expect(notes).not.toContain("add coverage"); + }); + + it("falls back to the raw commit list when node itself dies (notes must never block a release)", () => { + const cwd = createRepo(); + writeText(cwd, "src/app.txt", "fix\n"); + commitAll(cwd, "fix: survives node crash (#1)"); + git(cwd, ["tag", "v1.0.1"]); + + // fake `node` that always fails, first on PATH + const shimDir = join(cwd, "shim"); + mkdirSync(shimDir, { recursive: true }); + writeFileSync(join(shimDir, "node"), "#!/bin/sh\nexit 1\n", { mode: 0o755 }); + + const notes = execFileSync("bash", [SCRIPT, "v1.0.1"], { + cwd, + encoding: "utf-8", + env: { ...process.env, PATH: `${shimDir}:${process.env.PATH}` }, + stdio: ["ignore", "pipe", "pipe"], + }); + + expect(notes).toContain("- fix: survives node crash (#1)"); + }); + + it("produces grouped English fallback (not dictionary word salad) without LLM env", () => { const cwd = createRepo(); writeText(cwd, "src/app.txt", "translation fix\n"); commitAll(cwd, "fix(translation): preserve anthropic message roles (#1)"); @@ -151,13 +189,11 @@ describeIfBash("generate-release-notes.sh bash behavior", () => { const notes = runNotes(cwd, "v1.0.1"); - // Must contain English header and raw commit - expect(notes).toContain("## 🌐 English / 英文版"); - expect(notes).toContain("- fix(translation): preserve anthropic message roles (#1)"); - - // Must contain Chinese header and translated commit - expect(notes).toContain("## 🇨🇳 中文版 (翻译)"); - expect(notes).toContain("- 修复(翻译):保留 anthropic 消息角色 (#1)"); + expect(notes).toContain("### Fixes"); + expect(notes).toContain("preserve anthropic message roles (#1)"); + // the dictionary translator and its headers are gone + expect(notes).not.toContain("中文版 (翻译)"); + expect(notes).not.toContain("修复(翻译)"); }); }); diff --git a/tests/unit/ci/select-promote-candidate.test.ts b/tests/unit/ci/select-promote-candidate.test.ts new file mode 100644 index 00000000..4397f992 --- /dev/null +++ b/tests/unit/ci/select-promote-candidate.test.ts @@ -0,0 +1,156 @@ +import { execFileSync } from "child_process"; +import { existsSync, mkdtempSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join, resolve } from "path"; +import { beforeAll, describe, expect, it } from "vitest"; + +const ROOT = resolve(__dirname, "..", "..", ".."); +const SCRIPT = resolve(ROOT, ".github", "scripts", "select-promote-candidate.sh"); + +const NOW = 1_750_000_000; // fixed epoch for deterministic tests +const HOUR = 3600; +const DAY = 24 * HOUR; + +function git(cwd: string, args: string[], env: Record = {}): string { + return execFileSync("git", args, { + cwd, + encoding: "utf-8", + env: { ...process.env, ...env }, + stdio: ["ignore", "pipe", "pipe"], + }); +} + +function hasBash(): boolean { + try { + execFileSync("bash", ["-c", "exit 0"], { stdio: "ignore", timeout: 1000 }); + return true; + } catch { + return false; + } +} + +const describeIfBash = hasBash() ? describe : describe.skip; + +function commitAt(cwd: string, message: string, epoch: number, file = "file.txt"): string { + writeFileSync(join(cwd, file), `${message}\n${epoch}\n`); + git(cwd, ["add", "."]); + const date = `${epoch} +0000`; + git(cwd, ["commit", "-m", message], { + GIT_AUTHOR_DATE: date, + GIT_COMMITTER_DATE: date, + }); + return git(cwd, ["rev-parse", "HEAD"]).trim(); +} + +function createRepo(): string { + const cwd = mkdtempSync(join(tmpdir(), "codex-proxy-promote-test-")); + git(cwd, ["init", "-b", "master"]); + git(cwd, ["config", "user.name", "Test User"]); + git(cwd, ["config", "user.email", "test@example.com"]); + commitAt(cwd, "chore: base", NOW - 10 * DAY); + git(cwd, ["update-ref", "refs/remotes/origin/master", "HEAD"]); + git(cwd, ["checkout", "-b", "dev"]); + return cwd; +} + +function run(cwd: string, env: Record = {}): string[] { + const out = execFileSync("bash", [SCRIPT], { + cwd, + encoding: "utf-8", + env: { + ...process.env, + MASTER_REF: "refs/remotes/origin/master", + DEV_REF: "dev", + NOW_EPOCH: String(NOW), + ...env, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + return out.trim() === "" ? [] : out.trim().split("\n"); +} + +describeIfBash("select-promote-candidate.sh", () => { + beforeAll(() => { + expect(existsSync(SCRIPT), `script missing: ${SCRIPT}`).toBe(true); + }); + + it("returns dev HEAD when it is older than the soak window", () => { + const cwd = createRepo(); + const sha = commitAt(cwd, "fix: aged change", NOW - 2 * DAY); + expect(run(cwd)).toEqual([sha]); + }); + + it("returns empty when nothing on dev is ahead of master", () => { + const cwd = createRepo(); + expect(run(cwd)).toEqual([]); + }); + + it("returns empty when all dev commits are younger than the soak window", () => { + const cwd = createRepo(); + commitAt(cwd, "fix: too fresh", NOW - 2 * HOUR); + expect(run(cwd)).toEqual([]); + }); + + it("starvation fix: fresh HEAD does not block an aged ancestor from promoting", () => { + const cwd = createRepo(); + const aged = commitAt(cwd, "fix: soaked for two days", NOW - 2 * DAY); + const agedTip = commitAt(cwd, "fix: soaked 25h", NOW - 25 * HOUR); + commitAt(cwd, "fix: fresh, keeps soaking", NOW - 1 * HOUR); + + const candidates = run(cwd); + // newest eligible first, fresh commit absent + expect(candidates[0]).toBe(agedTip); + expect(candidates).toContain(aged); + expect(candidates).toHaveLength(2); + }); + + it("filters first-parent commits that are not descendants of master (sync-back merge)", () => { + // master gets a hotfix AFTER dev branched; dev then merges master back in + // (the repo's documented drift-recovery flow). Aged dev commits below the + // sync-back merge are NOT fast-forwards of master and must be excluded. + const cwd = createRepo(); + const belowMerge = commitAt(cwd, "fix: aged, predates master hotfix", NOW - 3 * DAY); + + git(cwd, ["checkout", "master"]); + commitAt(cwd, "fix: hotfix directly on master", NOW - 2 * DAY - HOUR, "hotfix.txt"); + git(cwd, ["update-ref", "refs/remotes/origin/master", "HEAD"]); + + git(cwd, ["checkout", "dev"]); + const mergeDate = `${NOW - 2 * DAY} +0000`; + git(cwd, ["merge", "--no-ff", "-m", "chore: sync master back into dev", "refs/remotes/origin/master"], { + GIT_AUTHOR_DATE: mergeDate, + GIT_COMMITTER_DATE: mergeDate, + }); + const mergeSha = git(cwd, ["rev-parse", "HEAD"]).trim(); + + const candidates = run(cwd); + expect(candidates).toEqual([mergeSha]); + expect(candidates).not.toContain(belowMerge); + }); + + it("rejects non-numeric MIN_AGE_SECONDS loudly", () => { + const cwd = createRepo(); + commitAt(cwd, "fix: aged", NOW - 2 * DAY); + expect(() => run(cwd, { MIN_AGE_SECONDS: "24h" })).toThrow(); + }); + + it("fails loudly on a missing ref instead of emitting an empty candidate list", () => { + const cwd = createRepo(); + commitAt(cwd, "fix: aged", NOW - 2 * DAY); + expect(() => run(cwd, { MASTER_REF: "refs/remotes/origin/nonexistent" })).toThrow(); + }); + + it("FORCE=true bypasses soak and returns dev HEAD", () => { + const cwd = createRepo(); + const fresh = commitAt(cwd, "fix: fresh hotfix", NOW - 1 * HOUR); + expect(run(cwd, { FORCE: "true" })).toEqual([fresh]); + }); + + it("caps output at MAX_CANDIDATES", () => { + const cwd = createRepo(); + for (let i = 0; i < 5; i++) { + commitAt(cwd, `fix: aged ${i}`, NOW - 5 * DAY + i * HOUR); + } + expect(run(cwd, { MAX_CANDIDATES: "3" })).toHaveLength(3); + }); +}); diff --git a/tests/unit/ci/summarize-release-notes.test.ts b/tests/unit/ci/summarize-release-notes.test.ts new file mode 100644 index 00000000..7e26a7c8 --- /dev/null +++ b/tests/unit/ci/summarize-release-notes.test.ts @@ -0,0 +1,221 @@ +import { execFileSync } from "child_process"; +import { existsSync } from "fs"; +import { resolve } from "path"; +import { beforeAll, describe, expect, it } from "vitest"; + +// Plain ESM script (no build step); vitest transforms it, tsc does not cover tests. +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore -- untyped .mjs CI script +import { buildPrompt, generateNotes, parseHighlights, renderFallback } from "../../../.github/scripts/summarize-release-notes.mjs"; + +const ROOT = resolve(__dirname, "..", "..", ".."); +const SCRIPT = resolve(ROOT, ".github", "scripts", "summarize-release-notes.mjs"); + +const COMMITS = [ + "- fix(ws): add connection timeout and abort handling in ws-transport", + "- feat: dashboard credit balance visualization", + "- fix: resolve ws response before codex.rate_limits bypass", +].join("\n"); + +const LLM_ENV = { + RELEASE_NOTES_BASE_URL: "https://llm.example/v1", + RELEASE_NOTES_API_KEY: "test-key", + RELEASE_NOTES_MODEL: "test-model", +}; + +const VALID_LLM_JSON = JSON.stringify({ + highlights_zh: ["修复 WebSocket 连接超时导致的请求卡死", "新增账号额度余额可视化面板"], + highlights_en: ["Fixed WebSocket timeouts hanging requests", "Added credit balance visualization"], +}); + +interface FetchStub { + fetchImpl: typeof fetch; + requests: { url: string; body: Record }[]; +} + +function stubLLM(status: number, content: string): FetchStub { + const requests: FetchStub["requests"] = []; + const fetchImpl = (async (url: unknown, init?: { body?: unknown }) => { + requests.push({ url: String(url), body: JSON.parse(String(init?.body)) }); + return { + ok: status >= 200 && status < 300, + status, + json: async () => ({ choices: [{ message: { role: "assistant", content } }] }), + }; + }) as unknown as typeof fetch; + return { fetchImpl, requests }; +} + +describe("summarize-release-notes generateNotes", () => { + it("produces bilingual notes from a valid LLM response, with raw commits in details", async () => { + const stub = stubLLM(200, VALID_LLM_JSON); + const out = await generateNotes({ tag: "v9.9.9", input: COMMITS, env: LLM_ENV, fetchImpl: stub.fetchImpl }); + + expect(out).toContain("## ✨ 本次更新"); + expect(out).toContain("修复 WebSocket 连接超时导致的请求卡死"); + expect(out).toContain("## What's New"); + expect(out).toContain("Fixed WebSocket timeouts hanging requests"); + expect(out).toContain("
"); + expect(out).toContain("fix(ws): add connection timeout and abort handling in ws-transport"); + + expect(stub.requests[0].url).toBe("https://llm.example/v1/chat/completions"); + expect(stub.requests[0].body.model).toBe("test-model"); + const messages = stub.requests[0].body.messages as { content: string }[]; + expect(messages.some((m) => m.content.includes("ws-transport"))).toBe(true); + }); + + it("falls back to grouped English list when LLM env is not configured", async () => { + const out = await generateNotes({ tag: "v9.9.9", input: COMMITS, env: {} }); + + expect(out).toContain("connection timeout and abort handling"); + expect(out).toContain("dashboard credit balance visualization"); + // no dictionary word salad, no fake-Chinese headers + expect(out).not.toContain("中文版 (翻译)"); + expect(out).not.toMatch(/[一-鿿]/); + // grouped by type + expect(out).toContain("### Fixes"); + expect(out).toContain("### Features"); + }); + + it("falls back when the LLM returns non-JSON garbage", async () => { + const stub = stubLLM(200, "Sure! Here are the notes you asked for."); + const out = await generateNotes({ tag: "v9.9.9", input: COMMITS, env: LLM_ENV, fetchImpl: stub.fetchImpl }); + expect(out).toContain("### Fixes"); + expect(out).not.toContain("## ✨ 本次更新"); + // validation failure is retried once before falling back + expect(stub.requests).toHaveLength(2); + }); + + it("falls back when the Chinese highlights contain no CJK", async () => { + const stub = stubLLM(200, JSON.stringify({ highlights_zh: ["all english"], highlights_en: ["all english"] })); + const out = await generateNotes({ tag: "v9.9.9", input: COMMITS, env: LLM_ENV, fetchImpl: stub.fetchImpl }); + expect(out).toContain("### Fixes"); + }); + + it("falls back when the LLM endpoint errors", async () => { + const stub = stubLLM(500, "{}"); + const out = await generateNotes({ tag: "v9.9.9", input: COMMITS, env: LLM_ENV, fetchImpl: stub.fetchImpl }); + expect(out).toContain("### Fixes"); + }); + + it("accepts JSON wrapped in markdown fences", async () => { + const stub = stubLLM(200, "```json\n" + VALID_LLM_JSON + "\n```"); + const out = await generateNotes({ tag: "v9.9.9", input: COMMITS, env: LLM_ENV, fetchImpl: stub.fetchImpl }); + expect(out).toContain("## ✨ 本次更新"); + }); + + it("passes through non-commit single-line input (Initial release)", async () => { + const out = await generateNotes({ tag: "v9.9.9", input: "Initial release", env: {} }); + expect(out).toBe("Initial release"); + }); + + it("includes the changelog excerpt in the prompt when provided", () => { + const prompt = buildPrompt("v1.0.0", ["- fix: a"], "## [Unreleased]\n- 修复某问题"); + expect(prompt).toContain("修复某问题"); + expect(prompt).toContain("highlights_zh"); + }); +}); + +describe("summarize-release-notes parseHighlights", () => { + it("rejects multi-line and oversized highlight items (markdown injection guard)", () => { + expect( + parseHighlights(JSON.stringify({ highlights_zh: ["修复\n## 假标题"], highlights_en: ["ok"] })), + ).toBeNull(); + expect( + parseHighlights(JSON.stringify({ highlights_zh: ["修" + "复".repeat(400)], highlights_en: ["ok"] })), + ).toBeNull(); + }); + + it("rejects empty, oversized, or non-string arrays", () => { + expect(parseHighlights(JSON.stringify({ highlights_zh: [], highlights_en: ["x"] }))).toBeNull(); + expect(parseHighlights(JSON.stringify({ highlights_zh: ["中文", 42], highlights_en: ["x", "y"] }))).toBeNull(); + expect( + parseHighlights( + JSON.stringify({ highlights_zh: Array(20).fill("中文条目"), highlights_en: Array(20).fill("entry") }), + ), + ).toBeNull(); + expect(parseHighlights("not json at all")).toBeNull(); + }); + + it("accepts a valid bilingual payload", () => { + const parsed = parseHighlights(VALID_LLM_JSON); + expect(parsed?.zh).toHaveLength(2); + expect(parsed?.en).toHaveLength(2); + }); +}); + +describe("summarize-release-notes renderFallback", () => { + it("groups commits by conventional type and strips prefixes", () => { + const out = renderFallback(["- fix(ws): repair sockets", "- feat: shiny thing", "- perf: faster", "- 1.2.3 misc"]); + expect(out).toContain("### Fixes\n\n- repair sockets"); + expect(out).toContain("### Features\n\n- shiny thing"); + expect(out).toContain("### Performance\n\n- faster"); + expect(out).toContain("### Other\n\n- 1.2.3 misc"); + }); + + it("groups breaking-change commits (feat!:) under their type", () => { + const out = renderFallback(["- feat!: breaking thing", "- fix(scope)!: breaking fix"]); + expect(out).toContain("### Features\n\n- breaking thing"); + expect(out).toContain("### Fixes\n\n- breaking fix"); + expect(out).not.toContain("### Other"); + }); +}); + +describe("summarize-release-notes renderNotes escaping", () => { + it("neutralizes HTML in commit lines so
cannot break the block", async () => { + const stub = stubLLM(200, VALID_LLM_JSON); + const out = await generateNotes({ + tag: "v9.9.9", + input: "- fix: close tag bold", + env: LLM_ENV, + fetchImpl: stub.fetchImpl, + }); + expect(out).not.toContain("close "); + expect(out).toContain("</details>"); + // exactly one real closing tag: the one renderNotes emits itself + expect(out.match(/<\/details>/g)).toHaveLength(1); + }); +}); + +describe("summarize-release-notes.mjs CLI", () => { + beforeAll(() => { + expect(existsSync(SCRIPT), `script missing: ${SCRIPT}`).toBe(true); + }); + + function runScript(input: string): string { + return execFileSync("node", [SCRIPT, "v9.9.9"], { + encoding: "utf-8", + input, + env: { ...process.env, RELEASE_NOTES_BASE_URL: "", RELEASE_NOTES_API_KEY: "", RELEASE_NOTES_MODEL: "" }, + timeout: 15000, + }); + } + + it("exits zero and emits grouped fallback without LLM env (release must not be blocked)", () => { + const out = runScript(COMMITS); + expect(out).toContain("### Fixes"); + expect(out).toContain("### Features"); + }); + + it("passes through single-line input", () => { + expect(runScript("Initial release").trim()).toBe("Initial release"); + }); + + it("exits zero with fallback when the LLM endpoint is unreachable (real fetch path)", () => { + // .invalid TLD resolves nowhere (RFC 2606) → fast DNS failure, both + // retries fail, grouped fallback, exit 0. Pins the never-blocks-release + // invariant through the exact subprocess mode release.yml uses. + const out = execFileSync("node", [SCRIPT, "v9.9.9"], { + encoding: "utf-8", + input: COMMITS, + env: { + ...process.env, + RELEASE_NOTES_BASE_URL: "http://release-notes-test.invalid/v1", + RELEASE_NOTES_API_KEY: "k", + RELEASE_NOTES_MODEL: "m", + }, + timeout: 30000, + }); + expect(out).toContain("### Fixes"); + }); +});