chore: improve release CI context and local secret env for release scripts

Made-with: Cursor
This commit is contained in:
ILoveBingLu
2026-04-03 01:33:25 +08:00
parent 6839901c1c
commit e0db6729c2
4 changed files with 125 additions and 9 deletions

View File

@@ -30,6 +30,9 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0
fetch-tags: true
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -17,6 +17,30 @@ git push origin v2.2.14
只有推送 `v*` 标签时GitHub Actions 才会自动构建和发布。
## 本地测试(不提交密钥)
发布相关脚本支持从本地私有环境文件读取密钥与模型配置,读取顺序为:
1. 进程环境变量(例如手动 `set` / CI 注入)
2. 仓库根目录 `.release.local.env`
3. 仓库根目录 `.env.local`
可用键(按需填写):
- `AI_API_KEY`
- `AI_API_URL`
- `AI_MODEL`
- `GH_TOKEN`
示例(文件不会被提交):
```env
AI_API_KEY=sk-xxxx
AI_API_URL=https://api.openai.com/v1/chat/completions
AI_MODEL=gpt-5.4
GH_TOKEN=ghp_xxxx
```
## GitHub Actions 会做什么
`.github/workflows/release.yml` 会在 `v*` 标签触发后执行:

View File

@@ -5,9 +5,51 @@ const rootDir = path.resolve(__dirname, '..')
const releaseDir = path.join(rootDir, 'release')
const contextPath = path.join(releaseDir, 'release-context.json')
const outputPath = path.join(releaseDir, 'release-body.md')
const aiApiKey = process.env.AI_API_KEY || ''
const aiApiUrl = process.env.AI_API_URL || 'https://api.openai.com/v1/chat/completions'
const aiModel = process.env.AI_MODEL || 'gpt-5.4'
function parseEnvText(content) {
const result = {}
for (const line of String(content || '').split(/\r?\n/)) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const eqIndex = trimmed.indexOf('=')
if (eqIndex <= 0) continue
const key = trimmed.slice(0, eqIndex).trim()
let value = trimmed.slice(eqIndex + 1).trim()
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
}
result[key] = value
}
return result
}
function loadLocalSecretEnv() {
const candidates = [
path.join(rootDir, '.release.local.env'),
path.join(rootDir, '.env.local')
]
const merged = {}
for (const filePath of candidates) {
if (!fs.existsSync(filePath)) continue
try {
const parsed = parseEnvText(fs.readFileSync(filePath, 'utf8'))
Object.assign(merged, parsed)
console.log(`[ReleaseBody] Loaded local env file: ${path.basename(filePath)}`)
} catch (e) {
console.warn(`[ReleaseBody] Failed to read local env file: ${filePath}`, String(e))
}
}
return merged
}
const localSecrets = loadLocalSecretEnv()
const aiApiKey = process.env.AI_API_KEY || localSecrets.AI_API_KEY || ''
const aiApiUrl = process.env.AI_API_URL || localSecrets.AI_API_URL || 'https://api.openai.com/v1/chat/completions'
const aiModel = process.env.AI_MODEL || localSecrets.AI_MODEL || 'gpt-5.4'
const PRIMARY_AUTHOR_LOGINS = new Set(['ILoveBingLu'])
const PRIMARY_AUTHOR_NAMES = new Set(['ILoveBingLu', 'BingLu', 'ILoveBinglu'])
@@ -128,8 +170,8 @@ async function generateAiBody(context) {
const systemPrompt = [
'你是一个发布说明撰写助手。',
'只能基于输入中的 commits 和 pull requests 生成,不得编造任何功能或修复。',
'输出必须是中文 Markdown。',
'必须包含以下章节',
'输出必须是中文 Markdown,风格尽量自然,不要机械复读。',
'为保证格式一致性:优先使用以下标题结构(即使某一类内容为空也要写出对应章节,并在该章节内标注“无/未检测到”)',
'## CipherTalk vX.Y.Z',
'### 概览',
'### 新增',
@@ -138,9 +180,12 @@ async function generateAiBody(context) {
'### 感谢贡献者',
'### 相关提交与 PR',
'如果存在最低安全版本或封禁版本,增加 ### 升级提醒 章节。',
'分类建议:可参考提交标题前缀 feat/fix 做粗分类到 新增/修复;其余放到 调整(如果标题无法判断,就放到 调整)。',
'引用规则:',
'有 PR 时优先引用 PR 标题;没有 PR 时才引用 commit 标题。',
'感谢规则:只有非主作者的 PR/commit 才出现在感谢段。',
'不要写模糊词,不要写猜测,不要写未在输入中出现的功能。'
'列表尽量短:最多每类列出 5 条最关键的标题;其余可在概览里用一句话说明总量。',
'感谢规则:只有非主作者的 PR/commit 才出现在感谢段;主作者按代码中的逻辑是 ILoveBingLu及其大小写/拼写变体)相关。',
'不要写猜测:如果输入里没有足够信息,就用“无/未检测到”或“仅维护性发布”描述。'
].join('\n')
const userPrompt = `请根据以下发布上下文为 ${context.tag} 生成标准化发布说明:\n\n${JSON.stringify(context, null, 2)}`

View File

@@ -7,9 +7,50 @@ const releaseDir = path.join(rootDir, 'release')
const owner = process.env.GITHUB_REPOSITORY_OWNER || 'ILoveBingLu'
const repo = (process.env.GITHUB_REPOSITORY || `${owner}/CipherTalk`).split('/')[1] || 'CipherTalk'
const currentTag = process.env.RELEASE_TAG || process.env.GITHUB_REF_NAME || ''
const ghToken = process.env.GH_TOKEN || ''
const pkg = require(path.join(rootDir, 'package.json'))
function parseEnvText(content) {
const result = {}
for (const line of String(content || '').split(/\r?\n/)) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const eqIndex = trimmed.indexOf('=')
if (eqIndex <= 0) continue
const key = trimmed.slice(0, eqIndex).trim()
let value = trimmed.slice(eqIndex + 1).trim()
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
}
result[key] = value
}
return result
}
function loadLocalSecretEnv() {
const candidates = [
path.join(rootDir, '.release.local.env'),
path.join(rootDir, '.env.local')
]
const merged = {}
for (const filePath of candidates) {
if (!fs.existsSync(filePath)) continue
try {
const parsed = parseEnvText(fs.readFileSync(filePath, 'utf8'))
Object.assign(merged, parsed)
console.log(`[ReleaseContext] Loaded local env file: ${path.basename(filePath)}`)
} catch (e) {
console.warn(`[ReleaseContext] Failed to read local env file: ${filePath}`, String(e))
}
}
return merged
}
const localSecrets = loadLocalSecretEnv()
const ghToken = process.env.GH_TOKEN || localSecrets.GH_TOKEN || ''
function runGit(command) {
return execSync(command, {
cwd: rootDir,
@@ -49,7 +90,10 @@ function getPreviousTag() {
function getCommitRange(previousTag, tag) {
if (!tag) return 'HEAD'
if (!previousTag || previousTag === tag) return tag
// 如果上一标签拿不到(例如 checkout 浅克隆/无 tags则退化成取 tag 前最近 50 次提交,
// 避免 release-context 里 commits/pullRequests 为空,导致后续 AI 发布说明内容很少。
if (!previousTag) return `${tag}~50..${tag}`
if (previousTag === tag) return tag
return `${previousTag}..${tag}`
}