From 95d1be9467cb344347bf27c79833c279209e6c29 Mon Sep 17 00:00:00 2001 From: icebear0828 Date: Tue, 9 Jun 2026 11:01:29 -0700 Subject: [PATCH 1/4] ci: LLM bilingual release notes, soak starvation fix, docker tag integrity, notify webhook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace 400-line word-by-word translate-notes.js dictionary with summarize-release-notes.mjs: one OpenAI-compatible LLM call produces user-facing Chinese highlights + English counterparts + collapsible raw commit list; strict JSON/CJK validation with grouped plain-English fallback; never exits non-zero so notes can't block a release - release.yml: notes job moved first (kills the empty-body window left by upload steps' 'gh release create --notes ""'); final job replaced by webhook notify; Node unified to 22 - promote-dev-to-master.yml: soak now promotes the newest first-parent dev commit >= 24h old (select-promote-candidate.sh) instead of requiring dev HEAD age — active weeks no longer starve master; CI gate walks candidates newest-first - docker-publish.yml: branch pushes tag latest + sha- only; ghcr.io vX.Y.Z images now build exclusively from the tag via bump-electron.yml dispatch (no more version-tag clobbering) - notify-webhook.sh: plain-text POST to NOTIFY_WEBHOOK_URL secret on release/promote success/failure; silent no-op when unset - align notes commit filter with bump SKIP_RELEASE_PATTERN --- .github/scripts/generate-release-notes.sh | 6 +- .github/scripts/notify-webhook.sh | 17 + .github/scripts/select-promote-candidate.sh | 40 ++ .github/scripts/summarize-release-notes.mjs | 189 ++++++ .github/scripts/translate-notes.js | 552 ------------------ .github/workflows/bump-electron.yml | 5 +- .github/workflows/docker-publish.yml | 47 +- .github/workflows/promote-dev-to-master.yml | 97 +-- .github/workflows/release.yml | 96 +-- CHANGELOG.md | 6 +- tests/unit/ci/release-notes-script.test.ts | 45 +- .../unit/ci/select-promote-candidate.test.ts | 120 ++++ tests/unit/ci/summarize-release-notes.test.ts | 171 ++++++ 13 files changed, 727 insertions(+), 664 deletions(-) create mode 100755 .github/scripts/notify-webhook.sh create mode 100755 .github/scripts/select-promote-candidate.sh create mode 100755 .github/scripts/summarize-release-notes.mjs delete mode 100644 .github/scripts/translate-notes.js create mode 100644 tests/unit/ci/select-promote-candidate.test.ts create mode 100644 tests/unit/ci/summarize-release-notes.test.ts diff --git a/.github/scripts/generate-release-notes.sh b/.github/scripts/generate-release-notes.sh index 64967619..3f56925b 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,5 @@ if [ -z "$BODY" ]; then BODY="Bug fixes and improvements" fi -printf '%s\n' "$BODY" | node "$(dirname "$0")/translate-notes.js" +printf '%s\n' "$BODY" | node "$(dirname "$0")/summarize-release-notes.mjs" "$TAG" 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..2bb3700b --- /dev/null +++ b/.github/scripts/select-promote-candidate.sh @@ -0,0 +1,40 @@ +#!/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)}" + +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 so every candidate is a state dev +# actually passed through (and a fast-forward of master by construction). +git rev-list --first-parent --format="%H %ct" --no-commit-header \ + "${MASTER_REF}..${DEV_REF}" \ + | awk -v cutoff="$CUTOFF" -v max="$MAX_CANDIDATES" \ + '$2 <= cutoff && n < max { print $1; n++ }' diff --git a/.github/scripts/summarize-release-notes.mjs b/.github/scripts/summarize-release-notes.mjs new file mode 100755 index 00000000..36abd28e --- /dev/null +++ b/.github/scripts/summarize-release-notes.mjs @@ -0,0 +1,189 @@ +#!/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; + const isStringList = (v) => + Array.isArray(v) && v.length > 0 && v.length <= MAX_HIGHLIGHTS && v.every((s) => typeof s === "string" && s.trim() !== ""); + if (!isStringList(zh) || !isStringList(en)) return null; + if (!CJK_RE.test(zh.join(""))) return null; + return { zh, en }; +} + +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.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); +} + +async function main() { + const tag = process.argv[2] ?? "unreleased"; + const input = fs.readFileSync(0, "utf-8"); + const notes = await generateNotes({ + tag, + input, + env: process.env, + changelogExcerpt: readChangelogExcerpt(), + }); + console.log(notes); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === fs.realpathSync(process.argv[1])) { + main().catch((error) => { + // Last-resort guard: emit stdin as-is rather than blocking the release. + console.error(`[release-notes] fatal: ${error instanceof Error ? error.message : error}`); + try { + console.log(fs.readFileSync(0, "utf-8").trim()); + } catch { + console.log("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..00c314e3 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,26 @@ 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 run: | - PKG_VERSION=$(node -p "require('./package.json').version") - - # 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") - - # 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" + SHORT_SHA=$(git rev-parse --short=7 HEAD) + { + echo "tags<> "$GITHUB_OUTPUT" - uses: docker/setup-qemu-action@v3 @@ -58,9 +59,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..ce834ca2 100644 --- a/.github/workflows/promote-dev-to-master.yml +++ b/.github/workflows/promote-dev-to-master.yml @@ -70,58 +70,69 @@ 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=$(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 + ') + 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 +146,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 @@ -177,3 +196,13 @@ jobs: 恢复方法:\`git merge -s ours origin/master\` 把 master 记为已合并(树取 dev),走 PR 进 dev,即可恢复 ff 前提。 失败 run:$RUN_URL" + + - 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 failed (ff precondition broken): $RUN_URL" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a0ff8417..cee9d74c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,52 @@ 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: Resolve tag + id: tag + run: | + TAG="${{ inputs.tag || github.ref_name }}" + echo "name=$TAG" >> "$GITHUB_OUTPUT" + + - name: Generate release notes and create release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + 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: | + 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). + PRERELEASE_FLAG="" + case "$TAG" in *-*) PRERELEASE_FLAG="--prerelease" ;; esac + + 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 + build: + needs: notes strategy: fail-fast: false matrix: @@ -42,7 +87,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 @@ -218,7 +263,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 @@ -348,44 +393,25 @@ 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 + - name: Send webhook notification + env: + NOTIFY_WEBHOOK_URL: ${{ secrets.NOTIFY_WEBHOOK_URL }} run: | TAG="${{ inputs.tag || github.ref_name }}" - echo "name=$TAG" >> "$GITHUB_OUTPUT" - - - name: Generate release notes and publish - 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 - - 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 - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + if [ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "true" ]; then + MSG="❌ Release $TAG failed: $RUN_URL" + else + MSG="✅ Release $TAG published: ${{ github.server_url }}/${{ github.repository }}/releases/tag/$TAG" + fi + bash .github/scripts/notify-webhook.sh "$MSG" diff --git a/CHANGELOG.md b/CHANGELOG.md index bae346a4..86277463 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(更新弹窗里用户看到空白);顺带 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..fcfe16f9 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,28 @@ 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("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 +168,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..36451c68 --- /dev/null +++ b/tests/unit/ci/select-promote-candidate.test.ts @@ -0,0 +1,120 @@ +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): string { + writeFileSync(join(cwd, "file.txt"), `${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("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..800b163a --- /dev/null +++ b/tests/unit/ci/summarize-release-notes.test.ts @@ -0,0 +1,171 @@ +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 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"); + }); +}); + +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"); + }); +}); From 7b249662b0dfa87d3d726f48314100e68937cd23 Mon Sep 17 00:00:00 2001 From: icebear0828 Date: Tue, 9 Jun 2026 11:18:15 -0700 Subject: [PATCH 2/4] fix(ci): address review findings on release pipeline overhaul - select-promote-candidate.sh: filter candidates with merge-base --is-ancestor -- first-parent commits below a master->dev sync-back merge are not fast-forwards and would reject the promote push; validate numeric env; avoid "[ ] &&" errexit footguns - release.yml notes job: stable releases created --latest=false so an asset-less release never becomes /releases/latest during the build window (or permanently, when smokes fail); notify job flips --latest only after all platforms succeed; edit/create wrapped in a 3-attempt retry without discarding stderr - docker-publish.yml: dispatching an old tag no longer moves "latest" backwards (only tagged when HEAD == master tip); tag input validated against semver (blocks GITHUB_OUTPUT heredoc injection); interpolation moved to env vars - promote pick loop: gh api failure skips the candidate (api-error) instead of aborting the job and firing a misleading ff-failure alarm; failure webhook text genericized; notify-ff-failure gets contents:read and sends the webhook before issue creation - summarize-release-notes.mjs: stdin captured once (fatal fallback no longer double-reads EOF and prints empty); highlights must be single-line <=300 chars (prompt-injection guard); commit lines HTML-escaped in details block; feat!: / fix(scope)!: grouped correctly - generate-release-notes.sh: node crash falls back to raw commit list instead of failing the release job (pipefail path) - tests: +6 covering all of the above (60/60 in tests/unit/ci) --- .github/scripts/generate-release-notes.sh | 10 ++- .github/scripts/select-promote-candidate.sh | 31 ++++++++-- .github/scripts/summarize-release-notes.mjs | 62 ++++++++++++------- .github/workflows/docker-publish.yml | 32 +++++++++- .github/workflows/promote-dev-to-master.yml | 29 +++++---- .github/workflows/release.yml | 60 +++++++++++++----- tests/unit/ci/release-notes-script.test.ts | 21 +++++++ .../unit/ci/select-promote-candidate.test.ts | 34 +++++++++- tests/unit/ci/summarize-release-notes.test.ts | 50 +++++++++++++++ 9 files changed, 269 insertions(+), 60 deletions(-) diff --git a/.github/scripts/generate-release-notes.sh b/.github/scripts/generate-release-notes.sh index 3f56925b..af0d0e43 100755 --- a/.github/scripts/generate-release-notes.sh +++ b/.github/scripts/generate-release-notes.sh @@ -68,5 +68,13 @@ if [ -z "$BODY" ]; then BODY="Bug fixes and improvements" fi -printf '%s\n' "$BODY" | node "$(dirname "$0")/summarize-release-notes.mjs" "$TAG" +# 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/select-promote-candidate.sh b/.github/scripts/select-promote-candidate.sh index 2bb3700b..ada1cc67 100755 --- a/.github/scripts/select-promote-candidate.sh +++ b/.github/scripts/select-promote-candidate.sh @@ -25,6 +25,13 @@ 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 + if [ "${FORCE:-false}" = "true" ]; then git rev-parse "$DEV_REF" exit 0 @@ -32,9 +39,21 @@ fi CUTOFF=$(( NOW_EPOCH - MIN_AGE_SECONDS )) -# First-parent keeps us on dev's mainline so every candidate is a state dev -# actually passed through (and a fast-forward of master by construction). -git rev-list --first-parent --format="%H %ct" --no-commit-header \ - "${MASTER_REF}..${DEV_REF}" \ - | awk -v cutoff="$CUTOFF" -v max="$MAX_CANDIDATES" \ - '$2 <= cutoff && n < max { print $1; n++ }' +# 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 index 36abd28e..b0e2b576 100755 --- a/.github/scripts/summarize-release-notes.mjs +++ b/.github/scripts/summarize-release-notes.mjs @@ -43,13 +43,25 @@ export function parseHighlights(raw) { } 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() !== ""); + 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"); @@ -65,7 +77,7 @@ export function renderNotes(highlights, commitLines) { "
", "完整提交记录 / Full commit list", "", - commitLines.join("\n"), + commitLines.map(escapeHtml).join("\n"), "", "
", ].join("\n"); @@ -82,7 +94,7 @@ export function renderFallback(commitLines) { const other = []; for (const line of commitLines) { const subject = line.replace(/^- /, ""); - const match = subject.match(/^([a-z]+)(?:\([^)]*\))?:\s*(.*)$/i); + 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) { @@ -164,26 +176,34 @@ export async function generateNotes({ tag, input, env, fetchImpl = fetch, change return renderFallback(commitLines); } -async function main() { +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"; - const input = fs.readFileSync(0, "utf-8"); - const notes = await generateNotes({ + generateNotes({ tag, - input, + input: capturedInput, env: process.env, changelogExcerpt: readChangelogExcerpt(), - }); - console.log(notes); -} - -if (process.argv[1] && fileURLToPath(import.meta.url) === fs.realpathSync(process.argv[1])) { - main().catch((error) => { - // Last-resort guard: emit stdin as-is rather than blocking the release. - console.error(`[release-notes] fatal: ${error instanceof Error ? error.message : error}`); - try { - console.log(fs.readFileSync(0, "utf-8").trim()); - } catch { - console.log("Bug fixes and improvements"); - } - }); + }) + .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/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 00c314e3..ac71c5d0 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -33,14 +33,40 @@ jobs: - name: Resolve image tags id: version + env: + TAG_INPUT: ${{ inputs.tag }} run: | + # Dispatch inputs are arbitrary strings; refuse anything that is not + # a plain semver tag (also blocks newline injection into the + # GITHUB_OUTPUT heredoc below). + if [ -n "$TAG_INPUT" ] && ! printf '%s' "$TAG_INPUT" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?$'; then + echo "::error::invalid tag input: $TAG_INPUT" + exit 1 + fi + SHORT_SHA=$(git rev-parse --short=7 HEAD) + + # 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" diff --git a/.github/workflows/promote-dev-to-master.yml b/.github/workflows/promote-dev-to-master.yml index ce834ca2..6f52d6fa 100644 --- a/.github/workflows/promote-dev-to-master.yml +++ b/.github/workflows/promote-dev-to-master.yml @@ -107,6 +107,9 @@ jobs: 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[] @@ -120,7 +123,7 @@ jobs: else "not-green" end end - ') + ') || STATUS="api-error" echo "$SHA → $STATUS" if [ "$STATUS" = "green" ]; then PICKED="$SHA" @@ -174,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 }} @@ -196,13 +213,3 @@ jobs: 恢复方法:\`git merge -s ours origin/master\` 把 master 记为已合并(树取 dev),走 PR 进 dev,即可恢复 ff 前提。 失败 run:$RUN_URL" - - - 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 failed (ff precondition broken): $RUN_URL" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cee9d74c..bde386d0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,28 +36,44 @@ jobs: - 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 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: | - 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). + # 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="" - case "$TAG" in *-*) PRERELEASE_FLAG="--prerelease" ;; esac + LATEST_FLAG="" + case "$TAG" in + *-*) PRERELEASE_FLAG="--prerelease" ;; + *) LATEST_FLAG="--latest=false" ;; + esac - 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 + 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 5 + done + if [ -z "$PUBLISHED" ]; then + echo "::error::could not create or update release $TAG after 3 attempts" + exit 1 + fi build: needs: notes @@ -354,9 +370,9 @@ 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 @@ -403,15 +419,27 @@ jobs: with: ref: ${{ inputs.tag || github.ref }} + - name: Mark stable release as latest (assets are complete now) + if: ${{ !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ inputs.tag || github.ref_name }} + run: | + case "$TAG" in + *-*) echo "prerelease, never latest" ;; + *) gh release edit "$TAG" --latest ;; + esac + - name: Send webhook notification 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: | - TAG="${{ inputs.tag || github.ref_name }}" - RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" if [ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "true" ]; then MSG="❌ Release $TAG failed: $RUN_URL" else - MSG="✅ Release $TAG published: ${{ github.server_url }}/${{ github.repository }}/releases/tag/$TAG" + MSG="✅ Release $TAG published: $RELEASE_URL" fi bash .github/scripts/notify-webhook.sh "$MSG" diff --git a/tests/unit/ci/release-notes-script.test.ts b/tests/unit/ci/release-notes-script.test.ts index fcfe16f9..227ac541 100644 --- a/tests/unit/ci/release-notes-script.test.ts +++ b/tests/unit/ci/release-notes-script.test.ts @@ -160,6 +160,27 @@ describeIfBash("generate-release-notes.sh bash behavior", () => { 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"); diff --git a/tests/unit/ci/select-promote-candidate.test.ts b/tests/unit/ci/select-promote-candidate.test.ts index 36451c68..eb9a6119 100644 --- a/tests/unit/ci/select-promote-candidate.test.ts +++ b/tests/unit/ci/select-promote-candidate.test.ts @@ -31,8 +31,8 @@ function hasBash(): boolean { const describeIfBash = hasBash() ? describe : describe.skip; -function commitAt(cwd: string, message: string, epoch: number): string { - writeFileSync(join(cwd, "file.txt"), `${message}\n${epoch}\n`); +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], { @@ -104,6 +104,36 @@ describeIfBash("select-promote-candidate.sh", () => { 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("FORCE=true bypasses soak and returns dev HEAD", () => { const cwd = createRepo(); const fresh = commitAt(cwd, "fix: fresh hotfix", NOW - 1 * HOUR); diff --git a/tests/unit/ci/summarize-release-notes.test.ts b/tests/unit/ci/summarize-release-notes.test.ts index 800b163a..7e26a7c8 100644 --- a/tests/unit/ci/summarize-release-notes.test.ts +++ b/tests/unit/ci/summarize-release-notes.test.ts @@ -117,6 +117,15 @@ describe("summarize-release-notes generateNotes", () => { }); 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(); @@ -143,6 +152,29 @@ describe("summarize-release-notes renderFallback", () => { 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", () => { @@ -168,4 +200,22 @@ describe("summarize-release-notes.mjs CLI", () => { 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"); + }); }); From 82f945cfe399fed2ce733e15d90e3e2a30d87f12 Mon Sep 17 00:00:00 2001 From: icebear0828 Date: Tue, 9 Jun 2026 11:29:39 -0700 Subject: [PATCH 3/4] fix(ci): harden release pipeline per second review round - release.yml notify: delete a zero-asset release when all builds fail (a published empty release would break beta clients resolving the newest prerelease; tag kept so re-dispatch can retry), retry the --latest flip 3x, webhook runs if always() and reports flip failures - release.yml: backstop gh release create in upload steps also passes --latest=false for stable tags; notes-job retry uses growing backoff - docker-publish.yml: tag validation switched to whole-string anchored bash regex (grep matched per-line, letting multiline payloads through) - select-promote-candidate.sh: verify MASTER_REF/DEV_REF exist up front (rev-list errors inside process substitution were swallowed and looked like a normal soak skip) - tests: +1 missing-ref loud-failure case (61/61) --- .github/scripts/select-promote-candidate.sh | 10 ++++ .github/workflows/docker-publish.yml | 7 +-- .github/workflows/release.yml | 50 +++++++++++++++---- .../unit/ci/select-promote-candidate.test.ts | 6 +++ 4 files changed, 60 insertions(+), 13 deletions(-) diff --git a/.github/scripts/select-promote-candidate.sh b/.github/scripts/select-promote-candidate.sh index ada1cc67..b48bccc6 100755 --- a/.github/scripts/select-promote-candidate.sh +++ b/.github/scripts/select-promote-candidate.sh @@ -32,6 +32,16 @@ for VAR in MIN_AGE_SECONDS MAX_CANDIDATES NOW_EPOCH; do 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 diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index ac71c5d0..1269191b 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -37,10 +37,11 @@ jobs: TAG_INPUT: ${{ inputs.tag }} run: | # Dispatch inputs are arbitrary strings; refuse anything that is not - # a plain semver tag (also blocks newline injection into the + # 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" ] && ! printf '%s' "$TAG_INPUT" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?$'; then - echo "::error::invalid tag input: $TAG_INPUT" + 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bde386d0..a900e0fc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -68,7 +68,7 @@ jobs: PUBLISHED=1; break fi echo "release edit/create failed (attempt $i/3), retrying..." - sleep 5 + sleep $(( i * 15 )) done if [ -z "$PUBLISHED" ]; then echo "::error::could not create or update release $TAG after 3 attempts" @@ -234,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) @@ -374,8 +375,9 @@ jobs: # 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 @@ -420,24 +422,52 @@ jobs: ref: ${{ inputs.tag || github.ref }} - 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: | - case "$TAG" in - *-*) echo "prerelease, never latest" ;; - *) gh release edit "$TAG" --latest ;; - 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 + + - 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') }}" = "true" ]; then + 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" diff --git a/tests/unit/ci/select-promote-candidate.test.ts b/tests/unit/ci/select-promote-candidate.test.ts index eb9a6119..4397f992 100644 --- a/tests/unit/ci/select-promote-candidate.test.ts +++ b/tests/unit/ci/select-promote-candidate.test.ts @@ -134,6 +134,12 @@ describeIfBash("select-promote-candidate.sh", () => { 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); From 536d41c765e1f853a95fccb73cf2d32f584582ab Mon Sep 17 00:00:00 2001 From: icebear0828 Date: Tue, 9 Jun 2026 11:34:09 -0700 Subject: [PATCH 4/4] docs: expand changelog entry with latest-flip and zero-asset cleanup --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86277463..1fc5ffc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,7 +44,7 @@ - 修复 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(更新弹窗里用户看到空白);顺带 release/build job Node 版本统一到 22(与 ci-quality 一致)(`.github/workflows/release.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`)。