Repository navigation
387 lines (360 loc) · 16.7 KB
/
Copy pathauto-analyze.yml
File metadata and controls
387 lines (360 loc) · 16.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
name: Auto Analyze & Publish
on:
schedule:
# 每小时触发一次;实际产量由 select_next_repo.py 的 MIN_SCORE / DAILY_CAP 节流:
# 仅在「有够分的新选题且最近 24h 未达产量上限」时才真正产出一篇,否则优雅空转跳过。
# 本仓库 public,标准 runner 的 Actions 分钟免费且无上限,频率不受其约束。
- cron: '0 * * * *'
workflow_dispatch:
inputs:
repo_url:
description: '指定 GitHub URL(留空则自动选高价值候选)'
required: false
default: ''
skip_publish:
description: '只分析,不入公众号草稿箱'
required: false
default: 'false'
type: choice
options: ['false', 'true']
force_backend:
description: '推理后端:留空=Minimax,anthropic=应急降级(需先配 ANTHROPIC_API_KEY)'
required: false
default: ''
issue_comment:
types: [created]
permissions:
contents: write
issues: write
concurrency:
group: auto-analyze
cancel-in-progress: false
jobs:
guard:
# issue_comment 触发时只放行 OWNER / COLLABORATOR / MEMBER 写的 /analyze
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.gate.outputs.should_run }}
trigger: ${{ steps.gate.outputs.trigger }}
steps:
- id: gate
env:
COMMENT_BODY: ${{ github.event.comment.body }}
COMMENT_ASSOC: ${{ github.event.comment.author_association }}
run: |
event="${{ github.event_name }}"
echo "trigger=$event" >> "$GITHUB_OUTPUT"
case "$event" in
schedule|workflow_dispatch)
echo "should_run=true" >> "$GITHUB_OUTPUT" ;;
issue_comment)
if [[ "$COMMENT_ASSOC" != "OWNER" && "$COMMENT_ASSOC" != "COLLABORATOR" && "$COMMENT_ASSOC" != "MEMBER" ]]; then
echo "::notice::非授权用户的评论,跳过"
echo "should_run=false" >> "$GITHUB_OUTPUT"
elif [[ "$COMMENT_BODY" != *"/analyze"* ]]; then
echo "::notice::评论里没有 /analyze 指令,跳过"
echo "should_run=false" >> "$GITHUB_OUTPUT"
else
echo "should_run=true" >> "$GITHUB_OUTPUT"
fi ;;
*)
echo "should_run=false" >> "$GITHUB_OUTPUT" ;;
esac
analyze:
needs: guard
if: needs.guard.outputs.should_run == 'true'
runs-on: ubuntu-latest
timeout-minutes: 45
env:
# 推理后端:Minimax 的 Anthropic 兼容端点,BASE_URL 写死在 run_skill.sh 里
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
# 应急降级:只有手动 dispatch 时选 force_backend=anthropic 才会用到
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
FORCE_BACKEND: ${{ github.event.inputs.force_backend || '' }}
# 微信草稿箱凭证:实际发布在 ali-demo 上跑(见下方 SSH 跳板 step),runner 不直连微信/反代。
# 这里保留 APPID/APPSECRET 仅供 setup_ci_env.sh 的 REQUIRE_PUBLISH fail-fast 校验。
WECHAT_APPID: ${{ secrets.WECHAT_APPID }}
WECHAT_APPSECRET: ${{ secrets.WECHAT_APPSECRET }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
cache-dependency-path: requirements-ci.txt
# 缓存用户级 tokei 二进制;key 含 setup_ci_env.sh hash → TOKEI_VERSION 改即失效
- name: Cache tokei binary
uses: actions/cache@v4
with:
path: ~/.local/bin/tokei
key: tokei-${{ runner.os }}-${{ hashFiles('src/scripts/setup_ci_env.sh') }}
# 缓存用户级 npm 全局目录(含 @anthropic-ai/claude-code);同上 key 策略
- name: Cache npm global (claude-code CLI)
uses: actions/cache@v4
with:
path: ~/.npm-global
key: npm-global-${{ runner.os }}-${{ hashFiles('src/scripts/setup_ci_env.sh') }}
- name: Bootstrap CI environment
env:
# skip_publish=true 时(手动只分析)无需发布凭证;其它路径(cron / issue_comment / dispatch 不填 skip_publish)都强制要求
REQUIRE_PUBLISH: ${{ github.event.inputs.skip_publish == 'true' && '0' || '1' }}
run: bash src/scripts/setup_ci_env.sh
- name: Decide target URL
id: pick
env:
COMMENT_BODY: ${{ github.event.comment.body }}
# 节流仅作用于 schedule 自动选题;dispatch 不给 URL 时 0/0=不节流(手动点即强出一篇)
MIN_SCORE: ${{ needs.guard.outputs.trigger == 'schedule' && '3' || '0' }}
DAILY_CAP: ${{ needs.guard.outputs.trigger == 'schedule' && '12' || '0' }}
run: |
set -euo pipefail
case "${{ needs.guard.outputs.trigger }}" in
workflow_dispatch)
given="${{ github.event.inputs.repo_url }}"
if [[ -n "$given" ]]; then
echo "repo_url=$given" >> "$GITHUB_OUTPUT"
echo "source=manual" >> "$GITHUB_OUTPUT"
exit 0
fi
;;
issue_comment)
printf '%s' "$COMMENT_BODY" | python3 src/scripts/parse_issue_comment.py
echo "source=issue" >> "$GITHUB_OUTPUT"
exit 0
;;
esac
# schedule 或 dispatch 未给 URL → 自动选
python3 src/scripts/select_next_repo.py
echo "source=auto" >> "$GITHUB_OUTPUT"
- name: Comment back on the issue (start)
if: needs.guard.outputs.trigger == 'issue_comment' && steps.pick.outputs.repo_url != ''
uses: actions/github-script@v7
with:
script: |
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `🤖 开始分析 ${{ steps.pick.outputs.repo_url }}\n查看运行: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`
});
- name: Run repo-miner
if: steps.pick.outputs.repo_url != ''
env:
REPO_URL: ${{ steps.pick.outputs.repo_url }}
run: |
set -euo pipefail
prompt="/repo-miner $REPO_URL,中间产物输出到 tmp/ ,最终分析报告输出到 src/analysis_report/{owner}_{name}.md"
bash src/scripts/run_skill.sh "$prompt" --timeout 1800 --log "tmp/logs/repo-miner.log"
- name: Locate produced report
id: report
if: steps.pick.outputs.repo_url != ''
run: |
set -euo pipefail
# repo-miner 完成后 src/analysis_report/ 应该多了一个 owner_name.md
# 用 git status 拿最新的 .md
new_md=$(git status --porcelain src/analysis_report/ \
| awk '/^\?\?|^.M/ {print $2}' \
| grep '\.md$' \
| head -1 || true)
if [[ -z "$new_md" ]]; then
echo "::error::没找到新生成的分析报告"
ls -la src/analysis_report/ | tail -20
exit 1
fi
echo "path=$new_md" >> "$GITHUB_OUTPUT"
echo "::notice::生成报告: $new_md"
- name: Prefix report title(统一加「GitHub推荐:」前缀)
# 确定性后处理:给新产报告的 H1 加前缀(幂等)。
# 必须在 md2wechat 之前,公众号 meta.json 的 title 才会带上前缀。
# 只改本次新产报告,不动历史。
if: steps.report.outputs.path != ''
run: python3 src/scripts/prefix_report_title.py "${{ steps.report.outputs.path }}"
- name: Run md2wechat (转 HTML + 元数据 JSON,不发布)
if: |
steps.report.outputs.path != '' &&
(github.event.inputs.skip_publish != 'true')
env:
REPORT_PATH: ${{ steps.report.outputs.path }}
run: |
set -euo pipefail
prompt="/md2wechat $REPORT_PATH 走方式三 CI 模式。只产出两个文件到 src/analysis_report/:(1) 同名 .html(微信兼容排版);(2) 同名 .meta.json,结构 {\"title\":\"...\",\"digest\":\"...(<=120 字)\",\"author\":\"NVoyager\",\"theme\":\"stars,universe,dark\"}。严禁 curl 任何 *.weixin.qq.com 或 wx.nightvoyager.top,发布动作由后续 Python 脚本完成。"
bash src/scripts/run_skill.sh "$prompt" --timeout 900 --log "tmp/logs/md2wechat.log"
# 校验产物
html_path="${REPORT_PATH%.md}.html"
meta_path="${REPORT_PATH%.md}.meta.json"
[[ -f "$html_path" ]] || { echo "::error::md2wechat 没产出 $html_path"; exit 1; }
[[ -f "$meta_path" ]] || { echo "::error::md2wechat 没产出 $meta_path"; exit 1; }
echo "::notice::产出 $html_path 和 $meta_path"
python3 -m json.tool < "$meta_path" > /dev/null || { echo "::error::$meta_path 不是合法 JSON"; exit 1; }
- name: 发布到草稿箱(SSH 跳板到 ali-demo)
id: publish
if: |
steps.report.outputs.path != '' &&
(github.event.inputs.skip_publish != 'true')
env:
REPORT_PATH: ${{ steps.report.outputs.path }}
SSH_KEY: ${{ secrets.ALI_DEMO_SSH_KEY }}
KNOWN_HOSTS: ${{ secrets.ALI_DEMO_KNOWN_HOSTS }}
TRIGGER: ${{ needs.guard.outputs.trigger }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
# 注意:本 step 故意不开 -e。SSH 失败时走 artifact 留种路径,
# 而非整 job fail(md 已经在前面 step 生成,发布是可异步重试的)
run: |
set -uo pipefail
mkdir -p ~/.ssh tmp/logs tmp/publish-pending
printf '%s\n' "$SSH_KEY" > ~/.ssh/ali_demo
chmod 600 ~/.ssh/ali_demo
printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
# 打包 md + meta.json
md_dir=$(dirname "$REPORT_PATH")
md_name=$(basename "$REPORT_PATH")
meta_name="${md_name%.md}.meta.json"
tar czf /tmp/payload.tgz -C "$md_dir" "$md_name" "$meta_name"
publish_ok=0
for i in 1 2 3; do
# SSH 走 forced-command 自动跑 publish-from-runner.sh
# stdin: tar.gz,stdout: last_publish.json,stderr: 日志(落 wechat-publish.log)
if ssh -i ~/.ssh/ali_demo \
-o UserKnownHostsFile=~/.ssh/known_hosts \
-o StrictHostKeyChecking=yes \
-o ConnectTimeout=30 \
root@47.113.202.7 \
< /tmp/payload.tgz \
> tmp/last_publish.json \
2> tmp/logs/wechat-publish.log; then
publish_ok=1
break
fi
echo "::warning::SSH 发布第 $i/3 次失败"
cat tmp/logs/wechat-publish.log >&2 || true
if [[ $i -lt 3 ]]; then
backoff=$((i * 15))
echo " 退避 ${backoff}s 后重试"
sleep "$backoff"
fi
done
if [[ $publish_ok -eq 1 ]]; then
echo "publish_status=success" >> "$GITHUB_OUTPUT"
echo "=== 远端日志 ==="
cat tmp/logs/wechat-publish.log
echo ""
echo "=== last_publish.json ==="
cat tmp/last_publish.json
else
# 留种到 tmp/publish-pending/,artifact 会带上;md 已 commit,可异步重发
cp /tmp/payload.tgz "tmp/publish-pending/$(date -u +%Y%m%d-%H%M%S)-${md_name%.md}.tgz"
echo "publish_status=deferred" >> "$GITHUB_OUTPUT"
echo "::warning::SSH 发布 3 次失败,已留种到 tmp/publish-pending/,artifact 可下载手动重发"
# 仅 issue_comment 触发时打标签到原 issue;其它路径仅 warning
if [[ "$TRIGGER" == "issue_comment" && -n "${ISSUE_NUMBER:-}" ]]; then
gh issue edit "$ISSUE_NUMBER" --add-label publish-pending \
|| echo "::warning::打 publish-pending 标签失败(label 可能未创建)"
fi
fi
- name: Record publish history (jsonl)
# 阶段 4:发布记录 SoR 从 publish.md 表格切换到 publish_history.jsonl
# (publish.md 已删除,历史已导入 publish_history.jsonl)
if: steps.report.outputs.path != ''
env:
PUBLISH_STATUS: ${{ steps.publish.outputs.publish_status }}
run: |
set -euo pipefail
ts="$(date -u +%Y-%m-%d)"
slug="$(basename ${{ steps.report.outputs.path }} .md)"
# 推到公众号草稿箱即视为已发布(published);
# 仅分析(skip_publish)/ SSH 发布失败留种(deferred)则仍是 pending。
if [[ "${{ github.event.inputs.skip_publish }}" == "true" ]]; then
state="pending"; reason="仅分析"
elif [[ "$PUBLISH_STATUS" == "deferred" ]]; then
state="pending"; reason="待重发"
else
state="published"; reason="自动生成"
fi
python3 src/scripts/record_publish.py \
--slug "$slug" --state "$state" \
--published-at "$ts" --reason "$reason" \
--ci-run-id "${{ github.run_id }}"
- name: Commit & Push
if: steps.report.outputs.path != ''
# 不开 -e:push 重试失败时本地 commit 已存,下次 cron 会带上,不应让 job fail
run: |
set -uo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add src/analysis_report/ src/data/publish_history.jsonl
if git diff --cached --quiet; then
echo "::notice::无变更可提交"
exit 0
fi
msg=$(cat <<MSG
feat(auto): 分析 ${{ steps.pick.outputs.repo_url }}
Triggered-By ${{ needs.guard.outputs.trigger }}
Run-URL ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
MSG
)
git commit -m "$msg"
# 3 次重试 + rebase 自愈临时冲突 / 网络抖动
# 仍失败时仅 ::error::,本地 commit 保留,下次 cron 触发 push 会自动带上
pushed=0
for i in 1 2 3; do
if git push; then
pushed=1
break
fi
echo "::warning::push 第 $i/3 次失败,尝试 rebase 后重试"
git pull --rebase --autostash origin main \
|| echo "::warning::rebase 也失败(第 $i 次),继续重试"
sleep $((i * 5))
done
if [[ $pushed -eq 0 ]]; then
echo "::error::3 次 push 均失败,本地 commit 已保留。下次 cron 触发会自动带上。"
# 不 exit 1:让后续 step(artifact 上传、issue 反馈)正常跑
fi
- name: Upload logs (always)
if: always()
uses: actions/upload-artifact@v4
with:
name: run-logs-${{ github.run_id }}
path: |
tmp/logs/
tmp/publish-pending/
tmp/*.md
retention-days: 14
if-no-files-found: ignore
- name: Comment back on the issue (done)
if: |
always() && needs.guard.outputs.trigger == 'issue_comment'
&& steps.pick.outputs.repo_url != ''
uses: actions/github-script@v7
with:
script: |
const ok = '${{ job.status }}' === 'success';
const report = '${{ steps.report.outputs.path }}';
const publishStatus = '${{ steps.publish.outputs.publish_status }}';
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
let body;
if (!ok) {
body = `❌ 分析失败,请看运行日志: ${runUrl}`;
} else if (publishStatus === 'deferred') {
body = `⚠️ 分析完成: \`${report}\`,已 commit 回主分支。\n` +
`但 SSH 发布 3 次失败,已留种到 artifact \`run-logs-${context.runId}\`,` +
`已打 \`publish-pending\` 标签,请稍后手动重发或等待异步重试。`;
} else {
body = `✅ 分析完成: \`${report}\`\n已 commit 回主分支,公众号草稿箱请去后台查看。`;
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body
});