Repository navigation
817 lines (763 loc) · 36.7 KB
/
Copy pathdeploy-split.yml
File metadata and controls
817 lines (763 loc) · 36.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
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
name: Deploy (split build/deploy)
# 分离式构建部署:把「构建」和「部署」拆到两类 runner 上。
#
# build job —— GitHub 托管 runner(ubuntu-latest)
# 只做「构建 + 打包组件 tar + 写 BUILD_META.json + 上传 artifact」,
# 不持有任何服务器凭据、不连接服务器。
# deploy job —— 服务器上的自托管 runner(runs-on: [self-hosted, ors-verify])
# 下载 artifact → 本机展开 → 备份 / 覆盖 / 重启 / 健康检查 / 失败回滚。
#
# 为什么这样拆:
# 1. 服务器到 github.com 的 git smart HTTP 实测不通,自托管 runner 跑不了
# actions/checkout,所以源码只能以「构建产物」形式经 artifact 过去;
# 2. 托管 runner 出口是动态公网 IP,直连服务器会在 auth.log 留异地登录记录,
# 改为自托管后服务器侧零入站连接、零异地登录。
#
# 部署脚本仍是同一份 deploy/deploy.py:build job 用 --build-artifacts 生产产物,
# deploy job 用 --from-artifacts 消费产物,两侧共用同一套打包定义,不会漂移。
#
# 2026-09-30 提速(依据同日本仓 run 36685781583 分步实测):
# 1. gate 与 build 无数据依赖,改为并行(原来串行白等 ~100s 墙钟);
# deploy 仍以 gate 结果为准入,安全边界不变。
# 2. 自托管 runner 对 job 引用的每个 Node action 都会在 Set up job 阶段从
# codeload.github.com 重新下载(实测 download-artifact 36s + upload-artifact
# 78s,且被 skip 的步骤也照下),故 deploy/rollback 两个自托管 job 彻底不引
# uses:;fallback 改 inline curl,日志改由尾段 hosted upload-logs job 上传。
# 3. 多连接分片 8 → 24 连接(8 连接相对单连接已有约 3 倍提升,24 为待实测值)。
on:
workflow_dispatch:
inputs:
environment:
description: "目标环境(prod 为生产,需填写确认词)"
type: choice
required: true
default: test
options: [test, prod]
action:
description: "deploy=部署;rollback=回滚到指定备份"
type: choice
required: true
default: deploy
options: [deploy, rollback]
components:
description: "部署组件"
type: choice
required: true
default: all
options: [all, frontend, backend, ai]
backup_id:
description: "回滚目标备份 id(action=rollback 时必填,可填 latest)"
type: string
required: false
default: ""
git_ref:
description: "代码分支(留空则 test→test、prod→dev)"
type: string
required: false
default: ""
skip_gate:
description: "紧急跳过测试门禁(会在日志中显著标注)"
type: boolean
required: true
default: false
confirm:
description: "生产确认词:environment=prod 时必须精确填写 DEPLOY-PROD"
type: string
required: false
default: ""
# 验证期:推送到该分支即触发一次完整流程(构建 → artifact → 自托管部署)。
# 刻意不含 pull_request,避免外部 PR 打到自托管 runner 上执行任意代码。
push:
branches: ["chore-cicd-split-deploy-verify"]
paths:
- "frontend/**"
- "backend/**"
- "ai/**"
- "deploy/**"
- ".github/workflows/deploy-split.yml"
permissions:
contents: read
actions: read
# 同一环境串行执行且不自动取消,避免两次发布互相覆盖
concurrency:
group: split-deploy-${{ inputs.environment || 'test' }}
cancel-in-progress: false
env:
PYTHON_VERSION: "3.11"
NODE_VERSION: "22"
# 服务器 /tmp 不可写,统一用家目录 ~/tmp 做暂存(自托管 runner 上展开为 $HOME/tmp)
DEPLOY_REMOTE_TMP: ${{ vars.DEPLOY_REMOTE_TMP || '~/tmp' }}
# 自托管 runner 上缓存的部署脚本(rollback 场景没有 artifact 可下载,用这份)
SELF_HOSTED_DIR: ${{ vars.DEPLOY_SELF_HOSTED_DIR || '~/ors-ci' }}
jobs:
# ---------------- 输入校验与参数解析 ----------------
plan:
name: Resolve plan
runs-on: ubuntu-latest
timeout-minutes: 5
# 只读仓库与 PR 列表:后者用于取「部署分支上最近一条已合并 PR」当通知主旨
permissions:
contents: read
pull-requests: read
outputs:
environment: ${{ steps.resolve.outputs.environment }}
action: ${{ steps.resolve.outputs.action }}
components: ${{ steps.resolve.outputs.components }}
git_ref: ${{ steps.resolve.outputs.git_ref }}
git_sha: ${{ steps.resolve.outputs.git_sha }}
commit_msg: ${{ steps.resolve.outputs.commit_msg }}
pr_no: ${{ steps.resolve.outputs.pr_no }}
pr_title: ${{ steps.resolve.outputs.pr_title }}
skip_gate: ${{ steps.resolve.outputs.skip_gate }}
health_urls: ${{ steps.resolve.outputs.health_urls }}
backup_keep: ${{ steps.resolve.outputs.backup_keep }}
artifact_name: ${{ steps.resolve.outputs.artifact_name }}
backup_id: ${{ steps.resolve.outputs.backup_id }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Validate and resolve
id: resolve
env:
EVENT: ${{ github.event_name }}
PUSH_REF: ${{ github.ref_name }}
IN_ENV: ${{ inputs.environment }}
IN_ACTION: ${{ inputs.action }}
IN_COMPONENTS: ${{ inputs.components }}
IN_REF: ${{ inputs.git_ref }}
IN_CONFIRM: ${{ inputs.confirm }}
IN_SKIP_GATE: ${{ inputs.skip_gate }}
IN_BACKUP_ID: ${{ inputs.backup_id }}
# 验证迭代期可把该仓库变量置 true 临时跳过门禁(默认不跳过)
FAST_GATE_SKIP: ${{ vars.SPLIT_DEPLOY_FAST }}
# 读 PR 列表需要 token(只用只读的 pull requests 权限)
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEALTH_TEST: ${{ vars.DEPLOY_TEST_HEALTH_URLS }}
HEALTH_PROD: ${{ vars.DEPLOY_PROD_HEALTH_URLS }}
BACKUP_KEEP: ${{ vars.DEPLOY_BACKUP_KEEP }}
run: |
set -euo pipefail
env_name="${IN_ENV:-test}"
action="${IN_ACTION:-deploy}"
components="${IN_COMPONENTS:-all}"
case "$env_name" in
test|prod) ;;
*) echo "::error::environment 只能是 test 或 prod(收到 '$env_name')"; exit 1 ;;
esac
case "$action" in
deploy|rollback) ;;
*) echo "::error::action 只能是 deploy 或 rollback(收到 '$action')"; exit 1 ;;
esac
case "$components" in
all|frontend|backend|ai) ;;
*) echo "::error::components 非法(收到 '$components')"; exit 1 ;;
esac
# 生产二次确认:确认词不符直接拦在门禁之前
if [ "$env_name" = "prod" ] && [ "$IN_CONFIRM" != "DEPLOY-PROD" ]; then
echo "::error::生产部署需在 confirm 输入框精确填写 DEPLOY-PROD"
exit 1
fi
# 代码分支:推送事件用被推的分支,手动触发用输入值(留空则按环境取默认)
ref="$IN_REF"
if [ "$EVENT" = "push" ]; then
ref="$PUSH_REF"
elif [ -z "$ref" ]; then
if [ "$env_name" = "prod" ]; then ref="dev"; else ref="test"; fi
fi
# 安全:只允许部署「本仓库已存在的分支」,阻断 owner:branch 等跨仓库 ref,
# 否则触发者能把 fork 上的任意代码当作待部署代码。
case "$ref" in
*:*) echo "::error::git_ref 不允许包含 ':'(该形式指向其他仓库的分支)"; exit 1 ;;
[!A-Za-z0-9]*|*[!A-Za-z0-9._/-]*)
echo "::error::git_ref 含非法字符,仅允许字母/数字/. _ / -,且需以字母或数字开头"; exit 1 ;;
esac
if ! git ls-remote --exit-code --heads origin "$ref" >/dev/null 2>&1; then
echo "::error::git_ref=$ref 不是本仓库的既有分支,已拒绝部署本仓库之外的代码"
exit 1
fi
sha=$(git ls-remote origin "refs/heads/$ref" | cut -f1)
if [ -z "$sha" ]; then echo "::error::无法解析分支 $ref 的提交号"; exit 1; fi
# 提交标题随通知推送,群里一眼看出这次发了什么;拿不到不阻断部署
commit_msg=""
if git fetch --depth 1 -q origin "refs/heads/$ref" >/dev/null 2>&1; then
commit_msg=$(git log -1 --format=%s FETCH_HEAD 2>/dev/null || true)
fi
# 通知主旨:该分支上「最近一条已合并的 PR」(test 环境取 base=test,prod 取 base=dev)。
# 拿不到(分支上还没有 PR / token 权限不足 / 网络异常)就留空,通知退回固定文案,
# 绝不因此阻断部署。$ref 已通过白名单校验,不会被当作选项注入。
pr_no=""
pr_title=""
if [ "$action" = "deploy" ]; then
pr_json=$(gh pr list --repo "$GITHUB_REPOSITORY" --base "$ref" --state merged \
--limit 1 --json number,title 2>/dev/null || true)
pr_no=$(printf '%s' "$pr_json" | jq -r '.[0].number // empty' 2>/dev/null || true)
pr_title=$(printf '%s' "$pr_json" | jq -r '.[0].title // empty' 2>/dev/null || true)
fi
# 回滚必须指定备份 id,否则 deploy.py 无从下手
backup_id="$IN_BACKUP_ID"
if [ "$action" = "rollback" ] && [ -z "$backup_id" ]; then
echo "::error::action=rollback 时必须填写 backup_id(可填 latest)"; exit 1
fi
# 测试门禁:手动触发看输入;推送事件默认跑,仅当仓库变量 SPLIT_DEPLOY_FAST=true 时跳过
skip_gate="false"
if [ "$EVENT" = "push" ]; then
if [ "$FAST_GATE_SKIP" = "true" ]; then skip_gate="true"; fi
elif [ "$IN_SKIP_GATE" = "true" ]; then
skip_gate="true"
fi
# 健康检查地址(由服务器本机 curl 探测)。
# test 算法服务是 9401;9411 属于另一个服务 controlled-ai-api,勿混用。
if [ "$env_name" = "test" ]; then
health="${HEALTH_TEST:-http://127.0.0.1:9400/api/health,http://127.0.0.1:9401/health,https://usp.ep-zl.com/t/app/}"
else
health="${HEALTH_PROD:-http://127.0.0.1:8400/api/health,http://127.0.0.1:8401/health,https://usp.ep-zl.com/p/app/}"
fi
{
echo "environment=$env_name"
echo "action=$action"
echo "components=$components"
echo "git_ref=$ref"
echo "git_sha=$sha"
echo "commit_msg=$commit_msg"
echo "pr_no=$pr_no"
echo "pr_title=$pr_title"
echo "skip_gate=$skip_gate"
echo "health_urls=$health"
echo "backup_keep=${BACKUP_KEEP:-10}"
echo "artifact_name=split-build-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
echo "backup_id=$backup_id"
} >> "$GITHUB_OUTPUT"
gate_label="启用"
if [ "$skip_gate" = "true" ]; then gate_label="**已跳过(紧急)**"; fi
{
echo "### 部署计划"
echo ""
echo "| 项 | 值 |"
echo "| --- | --- |"
echo "| 环境 | \`$env_name\` |"
echo "| 动作 | \`$action\` |"
echo "| 组件 | \`$components\` |"
echo "| 分支 | \`$ref\` |"
echo "| 提交 | \`${sha:0:8}\` |"
echo "| 测试门禁 | $gate_label |"
if [ "$action" = "rollback" ]; then echo "| 回滚目标 | \`$backup_id\` |"; fi
} >> "$GITHUB_STEP_SUMMARY"
# ---------------- 测试门禁(托管 runner) ----------------
gate:
name: Test gate (pytest + vitest)
needs: plan
if: ${{ !cancelled() && needs.plan.result == 'success' && needs.plan.outputs.action == 'deploy' && needs.plan.outputs.skip_gate != 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.plan.outputs.git_ref }}
# 分支尚未包含流水线改动时给出明确指引,避免出现难以理解的参数报错
- name: Verify deploy script supports CI flags
env:
GIT_REF: ${{ needs.plan.outputs.git_ref }}
run: |
set -euo pipefail
missing=""
for flag in --yes --remote-tmp --backup-keep --health-urls --rollback \
--list-backups --build-artifacts --from-artifacts; do
grep -q -- "$flag" deploy/deploy.py || missing="$missing $flag"
done
if [ -n "$missing" ]; then
echo "::error::分支 $GIT_REF 的 deploy/deploy.py 缺少流水线所需参数:$missing"
exit 1
fi
echo "deploy/deploy.py 参数就绪"
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Backend tests (pytest)
if: ${{ needs.plan.outputs.components == 'all' || needs.plan.outputs.components == 'backend' }}
working-directory: backend
env:
CI: "true"
run: |
set -euo pipefail
python -m pip install --upgrade pip
pip install -r requirements.txt -r requirements-test.txt
pytest --ignore=tests/tasks -q
- name: Frontend tests (vitest)
if: ${{ needs.plan.outputs.components == 'all' || needs.plan.outputs.components == 'frontend' }}
env:
CI: "true"
run: |
set -euo pipefail
npm ci --prefix frontend
npm run test --prefix frontend
- name: AI component notice
if: ${{ needs.plan.outputs.components == 'ai' }}
run: echo "::notice::AI 组件无自动门禁(ai/tests 依赖外部服务),本次跳过测试,请确认已人工验证"
# ---------------- 构建 + 上传 artifact(托管 runner) ----------------
# 与 gate 并行:build 只依赖 plan(产物与测试无数据依赖,串行纯浪费 ~100s 墙钟)。
# gate 失败时这里已跑完 = 白跑一次 hosted 构建(public 仓分钟免费),换取墙钟;
# 部署准入不受影响——deploy job 仍以 gate 结果为准入。
build:
name: Build artifacts (hosted)
needs: [plan]
if: ${{ !cancelled() && needs.plan.result == 'success' && needs.plan.outputs.action == 'deploy' }}
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.plan.outputs.git_ref }}
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install frontend deps
if: ${{ needs.plan.outputs.components == 'all' || needs.plan.outputs.components == 'frontend' }}
run: npm ci --prefix frontend
- name: Build components
env:
ENV_NAME: ${{ needs.plan.outputs.environment }}
COMPONENTS: ${{ needs.plan.outputs.components }}
GIT_REF: ${{ needs.plan.outputs.git_ref }}
GIT_SHA: ${{ needs.plan.outputs.git_sha }}
run: |
set -euo pipefail
python deploy/deploy.py --build-artifacts _artifacts \
-e "$ENV_NAME" -c "$COMPONENTS" \
--git-ref "$GIT_REF" --git-commit "$GIT_SHA"
# 部署脚本随产物一起过去:自托管 runner 无法 checkout 源码。
# 只带运行时真正需要的脚本——deploy/ 下的 OpenRobotDeploy.exe 是
# 12MB 的 Windows 打包二进制,带上只会白白拖慢 artifact 下载。
# notify_image.py + assets/fonts 供 NOTIFY_STYLE=image 渲染通知图(约 4.6MB 字体)。
mkdir -p _artifacts/deploy
cp deploy/deploy.py deploy/notify.py deploy/notify_image.py _artifacts/deploy/
cp -r deploy/assets _artifacts/deploy/assets
# 发布说明:prod 部署成功时通知「更新日志」卡片的内容来源(缺失不阻断构建,
# 此时 notify.py 会自动退回默认版面)
if [ -f docs/RELEASE_NOTES.md ]; then
cp docs/RELEASE_NOTES.md _artifacts/deploy/
fi
ls -lh _artifacts/deploy
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: ${{ needs.plan.outputs.artifact_name }}
path: _artifacts
if-no-files-found: error
retention-days: 7
- name: Summary
if: always()
run: |
{
echo "### 构建产物"
echo ""
echo "artifact:\`${{ needs.plan.outputs.artifact_name }}\`"
echo ""
echo '```json'
python -c "import json;print(json.dumps(json.load(open('_artifacts/BUILD_META.json')),ensure_ascii=False,indent=2))" || true
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
# ---------------- 下载 artifact + 本机部署(自托管 runner) ----------------
deploy:
name: Deploy (self-hosted)
# gate 与 build 并行后,deploy 同时等两者:build 产物到位 + gate 结果为准入
# (skip_gate=true 时 gate 为 skipped,走 needs.plan.outputs.skip_gate 分支)。
needs: [plan, gate, build]
if: ${{ !cancelled() && needs.plan.result == 'success' && needs.build.result == 'success' && needs.plan.outputs.action == 'deploy' && (needs.plan.outputs.skip_gate == 'true' || needs.gate.result == 'success') }}
runs-on: [self-hosted, ors-verify]
timeout-minutes: 30
environment: ${{ needs.plan.outputs.environment }}
outputs:
deploy_log: ${{ steps.export_log.outputs.deploy_log }}
steps:
# 自托管 runner 的 workspace 不会在两次运行之间清空,download-artifact
# 也只覆盖同名文件。残留的上一次 tar 会被 --from-artifacts 一起展开,
# 等于把旧代码混进本次部署,必须先清干净。
- name: Clean stale artifacts
run: |
set -euo pipefail
rm -rf _artifacts _artifact.zip _artifact.part*
echo "已清空 _artifacts"
# 多连接分片下载:服务器→artifact 存储(Azure blob)的国际链路单连接被 BDP
# 卡在 ~50KB/s,实测 8 连接可到 ~154KB/s(约 3 倍,11.4MB 下载 99s)。
# 24 连接是在此基础上的进一步尝试:若吞吐随连接数线性提升,预计下载可降到
# ~35s;若国际链路已饱和则收益递减,以下方打印的「用时 Xs」为准,
# 无效时把 N 调回 8 即可(一行改动,无其他副作用)。
# 失败不阻断部署:下一步 curl 单连接兜底(不用官方 action,原因见下)。
- name: Download artifacts (multi-connection)
id: mconn
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
ARTIFACT_NAME: ${{ needs.plan.outputs.artifact_name }}
run: |
set -euo pipefail
api="https://api.github.com/repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/artifacts"
body=$(curl -fsS -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" "$api")
aid=$(printf '%s' "$body" | jq -r --arg n "$ARTIFACT_NAME" '.artifacts[] | select(.name==$n) | .id' | head -1)
size=$(printf '%s' "$body" | jq -r --arg n "$ARTIFACT_NAME" '.artifacts[] | select(.name==$n) | .size_in_bytes' | head -1)
case "$aid" in ''|null) echo "::error::找不到产物 $ARTIFACT_NAME"; exit 1;; esac
case "$size" in ''|null) echo "::error::取不到产物大小"; exit 1;; esac
url=$(curl -sS -o /dev/null -D - -H "Authorization: Bearer $GH_TOKEN" \
"https://api.github.com/repos/$GITHUB_REPOSITORY/actions/artifacts/$aid/zip" \
| tr -d '\r' | awk 'tolower($1)=="location:"{print $2}')
test -n "$url" || { echo "::error::拿不到签名下载 URL"; exit 1; }
echo "artifact id=$aid size=${size}B"
N=24
chunk=$(( (size + N - 1) / N ))
t0=$(date +%s)
i=0
while [ "$i" -lt "$N" ]; do
from=$(( i * chunk ))
to=$(( from + chunk - 1 ))
[ "$to" -ge "$size" ] && to=$(( size - 1 ))
if [ "$from" -le "$to" ]; then
curl -fsS --retry 3 --retry-delay 2 -r "$from-$to" \
-o "_artifact.part$(printf '%02d' "$i")" "$url" &
fi
i=$(( i + 1 ))
done
wait
cat _artifact.part* > _artifact.zip
rm -f _artifact.part*
got=$(wc -c < _artifact.zip | tr -d ' ')
t1=$(date +%s)
echo "多连接下载完成:${got}B / ${size}B,用时 $((t1 - t0))s"
[ "$got" = "$size" ] || { echo "::error::分片下载字节数不符"; exit 1; }
rm -rf _artifacts
mkdir -p _artifacts
unzip -q -o _artifact.zip -d _artifacts
rm -f _artifact.zip
ls -lh _artifacts
# 兜底:多连接步骤失败(接口变动 / 网络抖动)时退回单连接 curl。
# 注意:不能用 actions/download-artifact@v4 兜底——自托管 runner 对 job 里
# 引用的每个 action 都会在 Set up job 阶段从 codeload.github.com 重新下载
# (实测 ~35s/个,且被 skip 的步骤也照下),一个从没执行过的 fallback 会
# 让每次部署白付这笔钱。取签名 URL 的方式与主路径一致(Bearer 只发给
# api.github.com,绝不随 302 落到 blob 域名)。
- name: Download artifacts (fallback, single-connection)
if: ${{ steps.mconn.outcome == 'failure' }}
env:
GH_TOKEN: ${{ github.token }}
ARTIFACT_NAME: ${{ needs.plan.outputs.artifact_name }}
run: |
set -euo pipefail
api="https://api.github.com/repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/artifacts"
body=$(curl -fsS -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" "$api")
aid=$(printf '%s' "$body" | jq -r --arg n "$ARTIFACT_NAME" '.artifacts[] | select(.name==$n) | .id' | head -1)
size=$(printf '%s' "$body" | jq -r --arg n "$ARTIFACT_NAME" '.artifacts[] | select(.name==$n) | .size_in_bytes' | head -1)
case "$aid" in ''|null) echo "::error::兜底下载:找不到产物 $ARTIFACT_NAME"; exit 1;; esac
case "$size" in ''|null) echo "::error::兜底下载:取不到产物大小"; exit 1;; esac
url=$(curl -sS -o /dev/null -D - -H "Authorization: Bearer $GH_TOKEN" \
"https://api.github.com/repos/$GITHUB_REPOSITORY/actions/artifacts/$aid/zip" \
| tr -d '\r' | awk 'tolower($1)=="location:"{print $2}')
test -n "$url" || { echo "::error::兜底下载:拿不到签名下载 URL"; exit 1; }
echo "兜底下载:artifact id=$aid size=${size}B(单连接)"
t0=$(date +%s)
curl -fsS --retry 3 --retry-delay 2 -o _artifact.zip "$url"
got=$(wc -c < _artifact.zip | tr -d ' ')
t1=$(date +%s)
echo "单连接下载完成:${got}B / ${size}B,用时 $((t1 - t0))s"
[ "$got" = "$size" ] || { echo "::error::兜底下载字节数不符"; exit 1; }
rm -rf _artifacts
mkdir -p _artifacts
unzip -q -o _artifact.zip -d _artifacts
rm -f _artifact.zip
ls -lh _artifacts
# upload-artifact 对「单个目录」是否保留目录名在不同版本间有过差异,
# 这里统一归一化,避免 _artifacts/_artifacts/... 之类的嵌套导致后续路径找不到
- name: Normalize artifact layout
run: |
set -euo pipefail
if [ -f _artifacts/_artifacts/BUILD_META.json ]; then
mv _artifacts/_artifacts _artifacts.norm
rm -rf _artifacts
mv _artifacts.norm _artifacts
fi
test -f _artifacts/BUILD_META.json
ls -lh _artifacts
- name: Show BUILD_META
run: |
set -euo pipefail
cat _artifacts/BUILD_META.json
# 把部署脚本留一份在 runner 上:回滚时没有 artifact 可下载,用这份
- name: Cache deploy scripts on runner
run: |
set -euo pipefail
dest="${SELF_HOSTED_DIR/#\~/$HOME}"
mkdir -p "$dest"
rm -rf "$dest/deploy"
cp -r _artifacts/deploy "$dest/deploy"
echo "已缓存部署脚本:$dest/deploy"
- name: Apply deployment
env:
ENV_NAME: ${{ needs.plan.outputs.environment }}
COMPONENTS: ${{ needs.plan.outputs.components }}
GIT_SHA: ${{ needs.plan.outputs.git_sha }}
BACKUP_KEEP: ${{ needs.plan.outputs.backup_keep }}
HEALTH_URLS: ${{ needs.plan.outputs.health_urls }}
run: |
set -o pipefail
python3 _artifacts/deploy/deploy.py \
--from-artifacts _artifacts \
-e "$ENV_NAME" -c "$COMPONENTS" \
--remote-tmp "$DEPLOY_REMOTE_TMP" \
--yes --no-sudo \
--git-commit "$GIT_SHA" \
--backup-keep "$BACKUP_KEEP" \
--health-urls "$HEALTH_URLS" \
--health-retries 20 \
--health-interval 3 2>&1 | tee deploy.log
rc=$?
backup_id=$(grep -oE 'DEPLOY_BACKUP_ID=[0-9a-z-]+' deploy.log | tail -n1 | cut -d= -f2 || true)
auto_rollback="no"
if grep -q "已自动回滚" deploy.log; then auto_rollback="yes"; fi
{
echo "DEPLOY_BACKUP_ID=$backup_id"
echo "DEPLOY_AUTO_ROLLBACK=$auto_rollback"
} >> "$GITHUB_ENV"
exit "$rc"
- name: Summary
if: always()
env:
ENV_NAME: ${{ needs.plan.outputs.environment }}
COMPONENTS: ${{ needs.plan.outputs.components }}
GIT_REF: ${{ needs.plan.outputs.git_ref }}
COMMIT_SHA: ${{ needs.plan.outputs.git_sha }}
SKIP_GATE: ${{ needs.plan.outputs.skip_gate }}
JOB_STATUS: ${{ job.status }}
run: |
set -uo pipefail
backup_id="${DEPLOY_BACKUP_ID:-(无)}"
auto_rollback="${DEPLOY_AUTO_ROLLBACK:-no}"
gate_label="已通过"
if [ "$SKIP_GATE" = "true" ]; then gate_label="**已跳过(紧急)**"; fi
rollback_label="否"
if [ "$auto_rollback" = "yes" ]; then rollback_label="**是(健康检查未通过,已回滚)**"; fi
{
echo "### 部署结果:$JOB_STATUS"
echo ""
echo "| 项 | 值 |"
echo "| --- | --- |"
echo "| 执行主机 | \`$(hostname)\` |"
echo "| 环境 | \`$ENV_NAME\` |"
echo "| 组件 | \`$COMPONENTS\` |"
echo "| 分支 | \`$GIT_REF\` @ \`${COMMIT_SHA:0:8}\` |"
echo "| 测试门禁 | $gate_label |"
echo "| 部署前备份 | \`$backup_id\` |"
echo "| 自动回滚 | $rollback_label |"
echo ""
echo "需要回滚时:Actions → Deploy (split build/deploy) → action=rollback"
echo "(备份 id 填 \`${backup_id}\` 或 \`latest\`)"
} >> "$GITHUB_STEP_SUMMARY"
# 日志导出:deploy.log 经 job output(base64 单行)交给尾段的 hosted
# upload-logs job 上传 artifact。自托管 runner 不引任何 uses:——引一个
# action 就要在 Set up job 阶段花 ~35~80s 重下一次。base64 字母表 shell
# 安全,可原样注入 upload job 的 printf;256KB 体积守卫防异常大日志。
# if: always() 保证部署失败现场(含自动回滚路径)的日志照样导出。
- name: Export deploy log to job output
id: export_log
if: always()
continue-on-error: true
run: |
set -uo pipefail
if [ ! -f deploy.log ]; then
echo "deploy.log 不存在(部署未进入执行阶段),跳过导出"
exit 0
fi
if [ "$(wc -c < deploy.log)" -gt 262144 ]; then
tail -c 262144 deploy.log > deploy.log.trimmed
mv deploy.log.trimmed deploy.log
printf '\n...[earlier output truncated]...\n' >> deploy.log
fi
{
printf 'deploy_log<<EOF_DEPLOY_LOG_B64\n'
base64 -w0 deploy.log
printf '\nEOF_DEPLOY_LOG_B64\n'
} >> "$GITHUB_OUTPUT"
echo "已导出 deploy.log($(wc -c < deploy.log) 字节)到 job output"
- name: Notify
if: always()
env:
NOTIFY_WEBHOOK: ${{ secrets.NOTIFY_WEBHOOK }}
NOTIFY_WEBHOOKS: ${{ secrets.NOTIFY_WEBHOOKS }}
NOTIFY_PROVIDER: ${{ vars.NOTIFY_PROVIDER || 'wecom' }}
NOTIFY_STYLE: ${{ vars.NOTIFY_STYLE || 'card' }}
NOTIFY_STATUS: ${{ job.status }}
NOTIFY_TITLE: 部署
PR_NO: ${{ needs.plan.outputs.pr_no }}
PR_TITLE: ${{ needs.plan.outputs.pr_title }}
EVENT_NAME: ${{ github.event_name }}
COMMIT_MSG: ${{ needs.plan.outputs.commit_msg }}
RUN_STARTED_AT: ${{ github.run_started_at }}
HEALTH_URLS: ${{ needs.plan.outputs.health_urls }}
ENV_NAME: ${{ needs.plan.outputs.environment }}
# 生产发布成功的「更新日志」卡片内容;文件随 artifact 带过来,缺失时 notify.py 自动降级
RELEASE_NOTES_FILE: _artifacts/deploy/RELEASE_NOTES.md
COMPONENTS: ${{ needs.plan.outputs.components }}
GIT_REF: ${{ needs.plan.outputs.git_ref }}
COMMIT_SHA: ${{ needs.plan.outputs.git_sha }}
SKIP_GATE: ${{ needs.plan.outputs.skip_gate }}
AUTO_ROLLBACK: ${{ env.DEPLOY_AUTO_ROLLBACK }}
ACTOR: ${{ github.actor }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: python3 _artifacts/deploy/notify.py
# ---------------- 回滚(自托管 runner,复用上次缓存的部署脚本) ----------------
rollback:
name: Rollback (self-hosted)
needs: plan
if: ${{ !cancelled() && needs.plan.result == 'success' && needs.plan.outputs.action == 'rollback' }}
runs-on: [self-hosted, ors-verify]
timeout-minutes: 20
environment: ${{ needs.plan.outputs.environment }}
env:
ENV_NAME: ${{ needs.plan.outputs.environment }}
BACKUP_ID: ${{ needs.plan.outputs.backup_id }}
HEALTH_URLS: ${{ needs.plan.outputs.health_urls }}
outputs:
rollback_log: ${{ steps.export_log.outputs.rollback_log }}
steps:
- name: Locate cached deploy script
id: locate
run: |
set -euo pipefail
dest="${SELF_HOSTED_DIR/#\~/$HOME}"
if [ ! -f "$dest/deploy/deploy.py" ]; then
echo "::error::runner 上尚无部署脚本($dest/deploy/deploy.py):请先成功跑一次 deploy。"
exit 1
fi
echo "script=$dest/deploy/deploy.py" >> "$GITHUB_OUTPUT"
- name: Rollback to backup
run: |
set -o pipefail
python3 "${{ steps.locate.outputs.script }}" \
--rollback "$BACKUP_ID" \
-e "$ENV_NAME" \
--local \
--remote-tmp "$DEPLOY_REMOTE_TMP" \
--yes --no-sudo \
--health-urls "$HEALTH_URLS" \
--health-retries 20 \
--health-interval 3 2>&1 | tee rollback.log
rc=$?
exit "$rc"
- name: Summary
if: always()
env:
JOB_STATUS: ${{ job.status }}
run: |
{
echo "### 回滚结果:$JOB_STATUS"
echo ""
echo "| 项 | 值 |"
echo "| --- | --- |"
echo "| 执行主机 | \`$(hostname)\` |"
echo "| 环境 | \`$ENV_NAME\` |"
echo "| 回滚目标 | \`$BACKUP_ID\` |"
} >> "$GITHUB_STEP_SUMMARY"
# 同 deploy job:日志经 job output 交给尾段 hosted upload-logs job,
# 自托管 job 不引 uses:(引一个 action 每次 run 就要多花 ~35~80s 下载)。
- name: Export rollback log to job output
id: export_log
if: always()
continue-on-error: true
run: |
set -uo pipefail
if [ ! -f rollback.log ]; then
echo "rollback.log 不存在(回滚未进入执行阶段),跳过导出"
exit 0
fi
if [ "$(wc -c < rollback.log)" -gt 262144 ]; then
tail -c 262144 rollback.log > rollback.log.trimmed
mv rollback.log.trimmed rollback.log
printf '\n...[earlier output truncated]...\n' >> rollback.log
fi
{
printf 'rollback_log<<EOF_ROLLBACK_LOG_B64\n'
base64 -w0 rollback.log
printf '\nEOF_ROLLBACK_LOG_B64\n'
} >> "$GITHUB_OUTPUT"
echo "已导出 rollback.log($(wc -c < rollback.log) 字节)到 job output"
- name: Notify
if: always()
env:
NOTIFY_WEBHOOK: ${{ secrets.NOTIFY_WEBHOOK }}
NOTIFY_WEBHOOKS: ${{ secrets.NOTIFY_WEBHOOKS }}
NOTIFY_PROVIDER: ${{ vars.NOTIFY_PROVIDER || 'wecom' }}
NOTIFY_STYLE: ${{ vars.NOTIFY_STYLE || 'card' }}
NOTIFY_STATUS: ${{ job.status }}
NOTIFY_TITLE: 回滚
ENV_NAME: ${{ needs.plan.outputs.environment }}
GIT_REF: ${{ needs.plan.outputs.git_ref }}
COMMIT_SHA: ${{ needs.plan.outputs.git_sha }}
COMMIT_MSG: ${{ needs.plan.outputs.commit_msg }}
BACKUP_ID: ${{ needs.plan.outputs.backup_id }}
EVENT_NAME: ${{ github.event_name }}
RUN_STARTED_AT: ${{ github.run_started_at }}
ACTOR: ${{ github.actor }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
# 回滚场景没有 artifact,用 runner 上缓存的 notify.py(与 deploy.py 同目录)
run: python3 "$(dirname '${{ steps.locate.outputs.script }}')/notify.py"
# ---------------- 日志上传(托管 runner) ----------------
# 为什么单独放一个 hosted job:
# 1. 自托管 runner 对 job 里引用的每个 Node action 都会在 Set up job 阶段
# 从 codeload.github.com 重新下载(实测单次 run 白付 ~110s,含从未执行的
# 兜底步骤),因此 deploy/rollback 两个自托管 job 已彻底不引 uses:;
# 2. GitHub 公开 REST API 只能下载/删除 artifact,没有上传端点,上传必须走
# runner 侧的 actions/upload-artifact;托管 runner 的 action 是镜像预缓存的
# (同 workflow 的 build job 实测 Set up job 仅 1~9s),放这里几乎零成本。
# 日志本体不走网络:deploy/rollback job 用 if: always() 把日志 base64 单行
# 写进 job output(部署失败现场也导出),本 job 还原后用官方 action 上传,
# artifact 名称/30 天保留/页面可直接下载的体验与改造前完全一致。
# 本 job 失败不影响部署结论与通知(通知已在 deploy/rollback job 末尾先行发出)。
upload-logs:
name: Upload logs (hosted)
needs: [plan, deploy, rollback]
if: ${{ !cancelled() && needs.plan.result == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
# action=deploy 时 rollback job 被 skip(outputs 为空),两个分支互斥
- name: Restore deploy.log
id: deploy_log
if: ${{ needs.deploy.outputs.deploy_log != '' }}
run: |
set -euo pipefail
# base64 字母表对 shell 安全,可原样注入单引号
printf '%s' '${{ needs.deploy.outputs.deploy_log }}' | base64 -d > deploy.log
wc -c deploy.log
- name: Upload deploy log
if: ${{ steps.deploy_log.outcome == 'success' }}
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: deploy-log-${{ needs.plan.outputs.environment }}-${{ github.run_id }}
path: deploy.log
if-no-files-found: warn
retention-days: 30
- name: Restore rollback.log
id: rollback_log
if: ${{ needs.rollback.outputs.rollback_log != '' }}
run: |
set -euo pipefail
printf '%s' '${{ needs.rollback.outputs.rollback_log }}' | base64 -d > rollback.log
wc -c rollback.log
- name: Upload rollback log
if: ${{ steps.rollback_log.outcome == 'success' }}
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: rollback-log-${{ needs.plan.outputs.environment }}-${{ github.run_id }}
path: rollback.log
if-no-files-found: warn
retention-days: 30