Repository navigation
967 lines (934 loc) · 63.4 KB
/
Copy pathbuild-release.yml
File metadata and controls
967 lines (934 loc) · 63.4 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
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
name: Build Release
# Phase 4 一键安装包构建流水线(rant #12 §11)。
# 触发:tag v* push。回归门禁:pytest 全绿后才构建。
#
# matrix:
# macos-15(arm64) → EMRG-<ver>-macos-arm64.pkg
# ubuntu-24.04(x86_64) → EMRG-<ver>-x86_64.AppImage + EMRG-<ver>-linux-x86_64.tar.gz + .run
# ubuntu-24.04-arm(aarch64) → EMRG-<ver>-aarch64.AppImage + EMRG-<ver>-linux-aarch64.tar.gz + .run
# windows-2025(x64) → EMRG-<ver>-windows-x64.exe
#
# 冒烟在无 Python runner 上跑(R111 验证干净机器承诺);离线冒烟(R96)
# 在 Linux job 用 iptables 断网执行。
#
# .run = Linux 无头服务器离线一键安装器(rant 2026-08-17T10:16:54):
# bash 自解压头 + runtime tarball payload,零网络零依赖。
on:
push:
tags: ["v*"]
workflow_dispatch:
jobs:
# 发布 tag 的**形态**属于发布的一部分:v0.2.94 起每一版都是 annotated tag,而
# Agent.md §Releasing 第 2 步写的那条 `git tag v<x.y.z>` 造出的是 **lightweight**
# tag —— 2026-09-20 的 v0.2.97 就是这样被打出去的(它是系列里唯一没有 `^{}`
# 解引用行的一个,事后被重打为 annotated)。规则在这里执行,而不是在文档里再写
# 一遍:判别信号已双向实测(`git/ref/tags/<tag>` 的 `object.type`:annotated →
# `tag`,lightweight → `commit`;v0.2.97/v0.2.96 → tag,v0.2.92/v0.2.93 → commit)。
#
# 该 job 故意排在 `build` 之前:一个 lightweight tag 应在几秒内失败,而不是先花
# 十五分钟把四个平台都构建完。`workflow_dispatch` 跑在分支上没有 tag 可查,故按
# ref 前缀跳过 —— 因此**本 job 在分支派发时是 skipped**,而 GitHub 的语义是
# **被 skip 的 needs 会把下游一起 skip**(实测 2026-09-20,run 35484455980:镜像
# 这两个 job 形状的探针里,`needs: <skipped job>` 的 job 结论是 skipped,对照组
# 正常执行)。所以 `build` 必须自己带条件,否则分支派发会静默变成「success 但
# 什么都没构建」——见下面 `build` 的 `if:`。`release` 自己的 `if:` 不受影响。
verify-tag:
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
permissions:
contents: read
steps:
- name: The release tag must be annotated
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
kind=$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${GITHUB_REF_NAME}" --jq '.object.type')
echo "tag ${GITHUB_REF_NAME}: object.type=${kind}"
if [ "$kind" != "tag" ]; then
echo "::error::${GITHUB_REF_NAME} is a lightweight tag (object.type=${kind}). Create release tags annotated: git tag -a v<x.y.z> -m \"emrg v<x.y.z>\""
exit 1
fi
# The tag's *agreement with the tree* is the other half of the same claim
# the annotated check makes (#1652). Everything the pipeline says about a
# release comes from the tag, while the artifacts' `version.txt` comes from
# `emrg.__version__` of the built tree (`packaging/build-runtime.sh`) -- so a
# tag cut on the wrong commit publishes a release whose name and whose
# `version.txt` disagree, and every host that installs it re-triggers the
# upgrade session every tick, forever: `UpgradeManager.tick` compares
# `version.txt` with `target.lstrip("v")`. The publish check cannot see it
# (its expected asset set is derived from the files this run built), so the
# comparison has to happen here, in seconds, before any platform builds.
# The same command is the host's pre-flight (Agent.md §Releasing step 2).
- uses: actions/checkout@v5
- name: The tag must name the version the tagged tree declares
run: |
set -euo pipefail
# `set -e` is load-bearing: the guard's exit 2 (not measurable) must fail
# this step too, never pass it as "nothing to compare".
python3 scripts/check-release-tag.py "${GITHUB_REF_NAME}"
# The credential question is asked **before** the build, for the same reason
# `verify-tag` above is: a credential Apple will refuse should cost seconds, not
# the fifteen minutes the four platforms take. Measured on tag v0.3.8, run
# 36956685533: all four legs built, the macOS leg died at "Notarize pkg", and the
# `release` job was skipped because of it -- **nothing was published**, and the
# host paid a full build round to learn what a one-second exchange at home (or
# here) answers first.
#
# The reading is `scripts/check-notary-credentials.py` -- the same exchange the
# notarize step makes, with the same three secrets under the same names -- and its
# exit codes are this job's verdict, all of them: 0 Apple accepted the credentials,
# 1 Apple refused them, 2 the exchange did not complete (no verdict was reached).
# Non-zero fails the job, 2 included: a gate that passes because it could not reach
# a verdict is the "unpublished release while green" this workflow's `release`
# comment forbids, and a run that cannot notarize cannot publish either.
#
# Gated on the same three secrets the notarize step is gated on, so a host who never
# configured notarization keeps the documented skip instead of gaining a blocker.
# `xcrun` is why the job is macOS rather than a second ubuntu job beside `verify-tag`.
verify-credentials:
runs-on: macos-15
if: startsWith(github.ref, 'refs/tags/')
env:
# Job-level env first: a secret cannot be read from an `if:` expression directly
# (GitHub rejects the workflow at parse time) -- the same shape `build` uses.
APPLE_ID: ${{ secrets.APPLE_ID }}
MACOS_NOTARY_APP_PASSWORD: ${{ secrets.MACOS_NOTARY_APP_PASSWORD }}
MACOS_NOTARY_TEAM_ID: ${{ secrets.MACOS_NOTARY_TEAM_ID }}
steps:
- uses: actions/checkout@v5
- name: The credentials the release notarizes with must still be accepted
if: env.APPLE_ID != '' && env.MACOS_NOTARY_APP_PASSWORD != '' && env.MACOS_NOTARY_TEAM_ID != ''
shell: bash
run: |
set -euo pipefail
# The preflight asks Apple a question **over the network**, so its exit 2 ("the
# exchange did not complete, so no verdict was reached") is also what a transport
# answer produces: the exchange's own 60 s bound, an `OSError` running `xcrun`, a
# 5xx, a DNS failure. This step is on the build job's path (`build: needs:
# [verify-tag, verify-credentials]`, and a failed dependency does not run it), and
# `release` needs `build` -- so one such blip skips the build, skips the release,
# and leaves the pushed tag with no release at all (issue #2201; the same
# consequence issue #2197 removes at the `Staple pkg` step and #2199/#2200 at the
# `Notarize pkg` step).
#
# So a `2` is retried, bounded; a `1` is Apple's verdict on the credential and is
# NEVER retried (only a human can change it); and an exchange that stays
# unmeasurable through every attempt still fails this step, because "could not
# measure" must never read as "nothing to check" -- the retry removes the flake,
# never the verdict (the rule PR #2198 states for the sibling step).
attempts=3
attempt=1
while [ "$attempt" -le "$attempts" ]; do
rc=0
python3 scripts/check-notary-credentials.py || rc=$?
if [ "$rc" -eq 0 ]; then
exit 0
fi
if [ "$rc" -eq 1 ]; then
echo "::error::the notarization preflight answered 1 -- Apple REFUSED these credentials (an expired or revoked app-specific password, an Apple ID or team ID that does not match, or a Developer Program agreement waiting to be accepted). This tag cannot publish a macOS product, and the four-platform build would be spent before saying so. Run the same command at home with the same three variables to read Apple's own reply."
exit 1
fi
if [ "$attempt" -lt "$attempts" ]; then
delay=$((attempt * 15))
echo "the notarization preflight answered ${rc} on attempt ${attempt}/${attempts} -- the exchange did not reach a verdict, which a transport blip produces; retrying in ${delay}s"
sleep "$delay"
fi
attempt=$((attempt + 1))
done
echo "::error::the notarization preflight answered ${rc} on every one of ${attempts} attempts -- the exchange never completed, so no verdict was reached (2 is this tool's code for a question it could not answer; 1, Apple's refusal, is reported above without a retry). This tag cannot publish a macOS product, and the four-platform build would be spent before saying so. Run the same command at home with the same three variables to read Apple's own reply."
exit 1
build:
needs: [verify-tag, verify-credentials]
# 两个 verify job 在分支派发时都是 skipped,而 skipped 的 needs 会 skip 下游 —— 不带
# 条件的话,`workflow_dispatch` 打在分支上会让整条流水线以 success 结束却什么都
# 不构建(master 上那条路径是真构建的)。此条件让本 job 在依赖 **成功或跳过** 时
# 都执行,在依赖 **失败** 时仍然不执行——后半段同样实测过(同一探针里 `needs:
# <failed job>` + 同一条件的 job 结论是 skipped),所以门禁的权力没有被削弱。
if: ${{ !failure() && !cancelled() }}
strategy:
fail-fast: false
matrix:
include:
- os: macos-15
arch: arm64
artifact: EMRG-*-macos-arm64.pkg
- os: ubuntu-24.04
arch: x86_64
artifact: EMRG-*-x86_64.AppImage
- os: ubuntu-24.04-arm
arch: aarch64
artifact: EMRG-*-aarch64.AppImage
- os: windows-2025
arch: x64
artifact: EMRG-*-windows-x64.exe
runs-on: ${{ matrix.os }}
env:
# secrets 不能在 if: 中直接引用(GitHub Actions 硬限制,否则 workflow 解析失败)。
# 先提升为 job 级 env(允许 secrets context);未配置时求值为空字符串 → 跳过降级。
MACOS_SIGNING_P12_BASE64: ${{ secrets.MACOS_SIGNING_P12_BASE64 }}
MACOS_SIGNING_P12_PASSWORD: ${{ secrets.MACOS_SIGNING_P12_PASSWORD }}
MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }}
# Installer 证书(rant 2026-08-06T15:26:双 p12 方案,宿主已配置 3 个新 Secret;
# 未配置时降级为单 p12 方案——Install 证书在 MACOS_SIGNING_P12_BASE64 内)
MACOS_INSTALLER_IDENTITY: ${{ secrets.MACOS_INSTALLER_IDENTITY }}
MACOS_INSTALLER_P12_BASE64: ${{ secrets.MACOS_INSTALLER_P12_BASE64 }}
MACOS_INSTALLER_P12_PASSWORD: ${{ secrets.MACOS_INSTALLER_P12_PASSWORD }}
# 公证(P2 后半,rant 2026-08-06T10:06:55):APPLE_ID 等 Secret 未配则跳过降级
APPLE_ID: ${{ secrets.APPLE_ID }}
MACOS_NOTARY_APP_PASSWORD: ${{ secrets.MACOS_NOTARY_APP_PASSWORD }}
MACOS_NOTARY_TEAM_ID: ${{ secrets.MACOS_NOTARY_TEAM_ID }}
# 内置默认 LLM 配置(宿主 2026-10-09「安装后马上就能用」):注入进每个安装包的
# 首次运行模板,使新装即带一份可用配置。**未配置时求值为空字符串 → 注入脚本 no-op,
# 产物与今天完全一致**,所以本组 Secret 是唯一的开关,加不加由宿主决定。
# 注意(宿主须知):安装包是公开下载物,注入的 key 可被任何人从产物中取出——
# 请用可轮换、有额度上限的 key;key 本身不进 git 历史,只存在于 Secret 与产物中。
EMRG_BUNDLED_BASE_URL: ${{ secrets.EMRG_BUNDLED_BASE_URL }}
EMRG_BUNDLED_API_KEY: ${{ secrets.EMRG_BUNDLED_API_KEY }}
EMRG_BUNDLED_MODEL: ${{ secrets.EMRG_BUNDLED_MODEL }}
EMRG_BUNDLED_CONTEXT_WINDOW: ${{ secrets.EMRG_BUNDLED_CONTEXT_WINDOW }}
EMRG_BUNDLED_MAX_TOKENS: ${{ secrets.EMRG_BUNDLED_MAX_TOKENS }}
EMRG_BUNDLED_AUTO_COMPACT_THRESHOLD: ${{ secrets.EMRG_BUNDLED_AUTO_COMPACT_THRESHOLD }}
EMRG_BUNDLED_VISION: ${{ secrets.EMRG_BUNDLED_VISION }}
steps:
- uses: actions/checkout@v5
- name: Setup uv (Python 3.13)
uses: astral-sh/setup-uv@v5
with:
python-version: "3.13"
- name: Setup Node 22
uses: actions/setup-node@v5
with:
node-version: "22"
# ── macOS 代码签名(rant 2026-08-06T10:06:55 P1/P2;Secret 未配则跳过降级)──
- name: Import signing certificate (macOS only)
if: runner.os == 'macOS' && env.MACOS_SIGNING_P12_BASE64 != ''
shell: bash
run: |
echo "$MACOS_SIGNING_P12_BASE64" | base64 --decode > /tmp/signing.p12
security create-keychain -p 'ci-temp' /tmp/ci.keychain
security default-keychain -s /tmp/ci.keychain
security set-keychain-settings -lut 21600 /tmp/ci.keychain
security unlock-keychain -p 'ci-temp' /tmp/ci.keychain
# 私钥存在性校验(v0.2.7 根因:p12 仅证书链无私钥 → set-key-partition-list 报
# "SecItemCopyMatching: item not found" 的 cryptic 错误)。实测(macOS 本机):
# p12 含私钥 → "N identity(ies) imported."(1 个单数、多 个复数);仅证书链 → "N certificates imported."
# import 输出捕获后校验 identity/identities 行,缺失即明确报错,指引宿主重新导出含私钥的 p12。
IMPORT_OUTPUT="$(security import /tmp/signing.p12 -k /tmp/ci.keychain -P "$MACOS_SIGNING_P12_PASSWORD" \
-T /usr/bin/codesign -T /usr/bin/productsign 2>&1)"
echo "$IMPORT_OUTPUT"
if [[ ! "$IMPORT_OUTPUT" =~ identit(y|ies)\ imported ]]; then
echo "::error::MACOS_SIGNING_P12_BASE64 未包含可签名私钥(security import 仅导入证书链)。请从 Keychain Access 导出含私钥的 p12(证书右键 → 导出 → 勾选\"包含私钥\"),重新 base64 后更新 GitHub Secret。import 输出:${IMPORT_OUTPUT}"
exit 1
fi
# 双 p12 方案(rant 2026-08-06T15:26):若配置了独立的 Installer p12
# Secret,则导入它(含 Developer ID Installer 证书+私钥),覆盖单 p12 方案。
# 未配置时降级为单 p12 方案(Installer 证书必须在 MACOS_SIGNING_P12_BASE64 内)。
if [ -n "${MACOS_INSTALLER_P12_BASE64:-}" ]; then
echo "==> 双 p12 方案:导入 Installer p12"
echo "$MACOS_INSTALLER_P12_BASE64" | base64 --decode > /tmp/installer.p12
INSTALLER_IMPORT="$(security import /tmp/installer.p12 -k /tmp/ci.keychain -P "$MACOS_INSTALLER_P12_PASSWORD" \
-T /usr/bin/codesign -T /usr/bin/productsign 2>&1)"
echo "$INSTALLER_IMPORT"
if [[ ! "$INSTALLER_IMPORT" =~ identit(y|ies)\ imported ]]; then
echo "::error::MACOS_INSTALLER_P12_BASE64 未包含可签名私钥(security import 仅导入证书链)。请重新导出含私钥的 Installer p12 后更新 GitHub Secret。import 输出:${INSTALLER_IMPORT}"
exit 1
fi
fi
# 双证书存在性校验(v0.2.7 第 7 次构建根因:p12 只有 Developer ID
# Application 证书——签 .app 成功,但 productsign 签 pkg 需要独立的
# Developer ID Installer 证书,缺失报 "An installer signing identity (not
# an application signing identity) is required" 的 cryptic 错误)。
# 支持两种方案(rant 2026-08-06T15:26):
# 单 p12 方案:MACOS_SIGNING_P12_BASE64 同时含 Application + Installer
# 双 p12 方案:MACOS_SIGNING_P12_BASE64 含 Application,MACOS_INSTALLER_P12_BASE64 含 Installer
# 两种证书必须同时存在。find-certificate 不受 policy 限制。
# ⚠️ 必须用输出判空而非退出码:实测 find-certificate 无匹配证书时
# 也返回 exit 0(正反两态验证,#455 教训)。
for CERT_NAME in "Developer ID Application" "Developer ID Installer"; do
if [ -z "$(security find-certificate -c "$CERT_NAME" -a /tmp/ci.keychain 2>/dev/null)" ]; then
echo "::error::keychain 缺少 $CERT_NAME 证书(签名必需)。Developer ID Application 用于签 .app(electron-builder codesign),Developer ID Installer 用于签 pkg(productsign),两张证书缺一不可。单 p12 方案:用 \`security export -t identities\` 连同私钥一并导出;双 p12 方案:检查 MACOS_INSTALLER_P12_BASE64 是否正确配置。"
exit 1
fi
done
# set-key-partition-list 与 electron-builder macCodeSign.js 完全一致:
# security set-key-partition-list -S apple-tool:,apple: -s -k <password> <keychain>
# -s = Match keys that can sign(必须显式提供才能定位私钥项目);
# -S apple-tool:,apple:(⚠️ 不可加 codesign: 分区——实测 v0.2.7 构建
# #453 加 codesign: 后仍 SecItemCopyMatching: item not found)。
security set-key-partition-list -S apple-tool:,apple: -s -k 'ci-temp' /tmp/ci.keychain
# SC2046: -s 需要将每个 keychain 路径作为独立参数传递,词分割是有意的
# shellcheck disable=SC2046
security list-keychains -d user -s /tmp/ci.keychain $(security list-keychains -d user | tr -d '"')
security find-identity -v -p codesigning /tmp/ci.keychain
# ── 回归门禁 ─────────────────────────────────────────────
- name: Python tests (regression gate)
run: |
uv sync --frozen
uv run pytest tests/ -v -n auto --dist loadfile
# ── GUI tests ────────────────────────────────────────────
- name: GUI unit tests
working-directory: emrg/gui
shell: bash
run: |
npm ci --omit=dev
EMRG_SKIP_INTEGRATION=1 npm test
- name: GUI integration tests (isolated HOME)
working-directory: emrg/gui
shell: bash
# Windows 上集成测试不可靠(spawn daemon + HOME 隔离语义不同,port 文件 15s 超时)
# → Windows 跳过,仅 POSIX 跑(unit tests 已覆盖协议逻辑)
if: runner.os != 'Windows'
run: npm run test:integration
# ── Runtime 构建(§3)────────────────────────────────────
- name: Build runtime (standalone python + source + lib)
shell: bash
run: bash packaging/build-runtime.sh
# ── CRLF 断言(rant 2026-08-13T09:44:32)────────────────
# git blob 的 *.cmd 是 LF,工作区 CRLF 靠 .gitattributes checkout 转换;
# 若 CI 检出未应用 .gitattributes,build-runtime 拷到的就是 LF →
# cmd.exe 整文件串行拼接 → 安装器 "stop-emrg.cmd exit code 1"。
# build-runtime.sh 已强制转 CRLF(R126),此步双保险验证产物确实是纯 CRLF。
- name: Verify runtime *.cmd are pure CRLF
shell: bash
run: |
set -euo pipefail
for f in dist/runtime/bin/emrg.cmd dist/runtime/bin/emrgd.cmd dist/runtime/bin/stop-emrg.cmd; do
[ -f "$f" ] || continue
cr=$(tr -cd '\r' < "$f" | wc -c)
lf=$(tr -cd '\n' < "$f" | wc -c)
if [ "$cr" -ne "$lf" ] || [ "$lf" -eq 0 ]; then
echo "::error::$f has bare-LF line(s) ($((lf-cr)) of $lf) — cmd.exe misparses LF → installer exit 1"
exit 1
fi
echo "OK $f: pure CRLF ($lf lines)"
done
# ── 图标资产生成(rant 2026-08-11T18:28:09:仓库只保留 icon.svg 设计源,
# png/icns/ico 由 CI 构建时现生成,必须先于 GUI 构建执行)──
- name: Generate icon assets (icon.svg → png/icns/ico)
shell: bash
run: |
set -euo pipefail
if command -v rsvg-convert >/dev/null 2>&1; then
echo "rsvg-convert already available"
elif [ "$RUNNER_OS" = "Linux" ]; then
# v0.2.55 lesson: a hung `sudo apt-get update`/install on a slow or
# network-starved runner blocks the job forever (no step timeout).
# Guard both with `timeout`; on failure gen-assets.sh falls back to
# Chrome headless (now with --no-sandbox + its own timeout).
timeout 180 sudo apt-get update -qq \
|| echo "apt-get update timed out/failed — falling back to Chrome headless"
timeout 180 sudo apt-get install -y -qq librsvg2-bin \
|| echo "apt-get install failed — falling back to Chrome headless"
elif [ "$RUNNER_OS" = "macOS" ]; then
brew install librsvg
else
# Windows: try to install librsvg (best effort); the fixed Chrome/Edge
# headless HTML-wrapper path in gen-assets.sh is the fallback
# (rant 2026-08-12T17:25:28: raw SVG screenshot was fully transparent).
if command -v choco >/dev/null 2>&1 && choco install -y librsvg >/dev/null 2>&1; then
echo "choco installed librsvg"
elif command -v winget >/dev/null 2>&1 && winget install --silent --accept-package-agreements librsvg >/dev/null 2>&1; then
echo "winget installed librsvg"
else
echo "no rsvg-convert; gen-assets.sh will fall back to Chrome/Edge headless (HTML wrapper)"
fi
fi
bash packaging/gen-assets.sh
# 产物必须存在(GUI 构建引用 ../packaging/assets/*.png/icns/ico)
test -s packaging/assets/icon.png
if [ "$RUNNER_OS" = "macOS" ]; then
test -s packaging/assets/icon.icns
fi
test -s packaging/assets/icon.ico
# ── React renderer (Vite) ── Batch 5 final switch 前置(cyc20260827-025722)──
# main.js 切换 loadFile → renderer/dist/index.html 前,先在发布流水线验证
# vite build 在全部 4 个 runner 可用 + dist 被 electron-builder 正确打包。
# test.yml PR CI 已跑同款命令(ubuntu+windows),此步补齐 macOS/arm 验证。
- name: Build React renderer (Vite)
working-directory: emrg/gui/renderer
shell: bash
run: |
npm ci
npm run build
test -f dist/index.html
# ── 打包引用自查(cycle cyc20261009-032401)──────────────────────────
# electron-builder 把 `from`/`directories.*` 相对 emrg/gui/package.json 解析,源不存在时
# **只警告不失败**:run 37723424917(v0.3.9)就带着这两行发布了没有载荷的产物 ——
# • file source doesn't exist from=.../emrg/emrg/dist/runtime
# • default Electron icon is used reason=application icon is not set
# 后果集中在 Linux:main.js 的 ensureAppImageExtracted 首启复制 resourcesPath/runtime 到
# ~/.emrg/install,缺它则装完的 AppImage 没有运行时。这一步在构建读取之前断言引用可解析
# (宿主自查同一条命令,见脚本 --help;PR 阶段由 emrg/gui/test/build-config.test.js 钉住)。
- name: Assert electron-builder sources resolve (emrg/gui)
shell: bash
run: python3 scripts/check-gui-package-refs.py
# ── 内置默认配置 → 打包 GUI 的源(宿主 2026-10-09「安装后马上就能用」)──
# 打包模式的 GUI 不 spawn python,它把首次运行模板内联在 emrg/gui/main.js 里
# (CONFIG_TEMPLATE,app.isPackaged 分支),而 electron-builder 是从工作区就地
# 打包这个文件的 —— 所以注入必须发生在它被打包之前,作用域也只给到 emrg/gui
# (给整个工作区会让文档/测试/会话记录里的模板副本一起被改写,实测 10 个文件)。
# 未配置 EMRG_BUNDLED_* → 脚本 no-op,产物与今天逐字节一致。
- name: Bundle default config into the packaged GUI source
shell: bash
run: python3 packaging/bundle_default_config.py --source-root emrg/gui
# ── GUI dist(electron-builder;linux extraResources runtime §5 R64)──
- name: Build GUI (electron-builder)
working-directory: emrg/gui
shell: bash
run: |
npm ci
# macOS 签名:仅在 macOS + 有证书时导出 CSC_LINK/CSC_KEY_PASSWORD,
# 激活 electron-builder 自动 codesign(含 hardened runtime)。
# ⚠️ 不可在 step env 里设空字符串:electron-builder 对空 CSC_LINK 仍走签名分支
# (path.resolve("") = 工作目录 → "WIN_CSC_LINK is not correct ... not a file",
# v0.2.7 windows job 失败根因)。未设置 = 不签名,保持降级。
if [ "$RUNNER_OS" = "macOS" ] && [ -n "${MACOS_SIGNING_P12_BASE64:-}" ]; then
export CSC_LINK="file:///tmp/signing.p12"
export CSC_KEY_PASSWORD="$MACOS_SIGNING_P12_PASSWORD"
fi
npm run dist
# ── 捆绑 git/gh(§6 R59:macOS/Linux 源码编译,Windows portable)──
- name: Bundle git + gh
shell: bash
run: bash packaging/bundle-git-gh.sh
# ── 安装器(§10)────────────────────────────────────────
# Linux 腿先备好 AppImage 可更新化的两件工具(rant 2026-09-30T11:42:41 要求 2):
# electron-builder 产出的 AppImage 里没有更新信息,修法是上游给的那条唯一的路
# ——`appimagetool -u` 重新封装(内嵌 + 生成 `.zsync`),而它需要 `zsyncmake`
# 才会写 `.zsync`、需要 `mksquashfs` 才能封装。这里装齐,`make-installer.sh`
# 那一步才有工具可按;缺工具时它**失败**而不是降级(一个不内嵌更新信息的
# AppImage 正是这条 rant 要修的东西,静默跳过等于把告警留着还让流水线变绿)。
- name: AppImage update tooling (Linux only)
if: runner.os == 'Linux'
shell: bash
run: |
set -euo pipefail
sudo apt-get update -qq
sudo apt-get install -y -qq zsync squashfs-tools
command -v zsyncmake
command -v mksquashfs
# appimagetool 本身是个 AppImage(Go 静态二进制),CI 里没有 FUSE,
# 用 APPIMAGE_EXTRACT_AND_RUN=1 就地解出来跑(同一个变量 make-installer
# 调它时也带着)。`--version` 是这条安装步骤的判据:装上了才算备好。
curl -fsSL -o /tmp/appimagetool \
"https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$(uname -m).AppImage"
chmod +x /tmp/appimagetool
sudo mv /tmp/appimagetool /usr/local/bin/appimagetool
APPIMAGE_EXTRACT_AND_RUN=1 appimagetool --version
- name: Make installer
shell: bash
run: bash packaging/make-installer.sh
# ── zip 安装包(Windows only,rant 2026-08-10T20:10:41)──
# 宿主反馈:除 exe 安装包外再提供 zip 压缩版(内容 = 同名 exe),
# 方便直接解压使用/分发。压缩后产物 EMRG-<ver>-windows-x64.zip
# 走下方 Upload artifacts(glob 需含 *.zip)。
- name: Zip installer (Windows only)
if: runner.os == 'Windows'
shell: bash
run: |
cd dist/artifacts
EXE="$(find . -maxdepth 1 -name 'EMRG-*-windows-x64.exe' | head -1)"
if [ -z "$EXE" ]; then
echo "::error::no EMRG-*-windows-x64.exe found in dist/artifacts"
exit 1
fi
ZIP="${EXE%.exe}.zip"
powershell -NoProfile -Command "Compress-Archive -Force -Path \"$EXE\" -DestinationPath \"$ZIP\""
ls -la
test -s "$ZIP"
# ── 图标透明性:读本次构建的 pkg(issue #2283)──────────────────
# 这一步的主语是**这个 pkg 里的那份 icon.icns**。上面 `Generate icon assets` 只断言
# 文件非空(`test -s`),而烤进白边的渲染同样非空 —— 它通过本仓库跑的每一条门禁,宿主
# 装完却看见白圈(rant 2026-10-13T17:45:42)。实测 2026-10-14(发布版 v0.3.12 的 pkg,
# sha256 fdc0c380e2b1f273e0c41391568dbf6409c81b232a5655343c955097a0323de3):展开后
# 读它的 payload 花 1.5 秒,8 个 PNG 表示全 `clear`;把同一个图标换成不透明的合成 pkg
# 读出来是 `FAULT`(rc 1)。所以这条验收在上传前、在产物本身上就能满足。
# 两个失败答案在语义上不同,且都不是通过:rc 1「图标不透明」,rc 2「读不出来」。
- name: Assert the pkg's icon kept its transparency (macOS only)
if: runner.os == 'macOS'
shell: bash
run: |
set -euo pipefail
PKG="$(find dist/artifacts -maxdepth 1 -name 'EMRG-*-macos-*.pkg' | head -1)"
# 不跳过(issue #1913):这一步的主语就是 pkg。缺席时印 "skipping" + exit 0
# 等于给一次**没有 macOS 产物**的发布盖章。
if [ -z "$PKG" ]; then
echo "::error::no EMRG-*-macos-*.pkg found in dist/artifacts — this step's subject is the pkg, and a release missing its macOS product must not be reported as success"
exit 1
fi
ROOT="$(mktemp -d "${TMPDIR:-/tmp}/emrg-icon-XXXXXX")"
# pkg 是 productbuild 的 distribution 套一个组件包(packaging/make-installer.sh),
# 所以 app 在组件的 Payload 里:`pkgutil --expand` + gzip/cpio —— 与 DEVELOPMENT.md
# 写给宿主的那条路完全一致,本地读和这里读是同一次读数。
pkgutil --expand "$PKG" "$ROOT/expanded"
mkdir -p "$ROOT/payload"
found=0
while IFS= read -r -d '' payload; do
found=1
(cd "$ROOT/payload" && gunzip -dc "$payload" | cpio -i -d --quiet)
done < <(find "$ROOT/expanded" -name Payload -type f -print0)
if [ "$found" -eq 0 ]; then
echo "::error::expanded $PKG but it carries no component Payload — the icon cannot be read out of this artifact, and a subject that cannot be read is not a pass"
exit 1
fi
rc=0
uv run --no-sync python3 scripts/check-icon-transparency.py "$ROOT/payload" || rc=$?
if [ "$rc" -eq 1 ]; then
echo "::error::$PKG ships an icon that is opaque at its corners (the values are printed above) — the render baked a background in, which is the white ring of rant 2026-10-13T17:45:42. Re-render with packaging/gen-assets.sh, and read the result back with this same command before cutting the tag again."
elif [ "$rc" -eq 2 ]; then
echo "::error::no icon could be read out of $PKG — the payload was extracted, but the tool found nothing it reads under it, or what it found would not decode. 'could not measure' is not a pass: check that the GUI build put EMRG.app's Resources/icon.icns into the payload."
elif [ "$rc" -ne 0 ]; then
echo "::error::scripts/check-icon-transparency.py exited $rc, which is neither this tool's fault code (1) nor its unmeasurable one (2) — the tool itself failed, so nothing was read about the artifact."
fi
exit "$rc"
# ── pkg 签名(macOS only,P2;Secret 未配则跳过降级)──
# ⚠️ productsign 需要 Developer ID Installer 身份(非 Application 身份)——
# 实测 #462:MACOS_SIGNING_IDENTITY 配 Application 身份报
# "An installer signing identity (not an application signing identity) is required"。
# 优先用宿主配置的 MACOS_INSTALLER_IDENTITY(rant 2026-08-06T15:26 双 p12 方案),
# 未配置则从 keychain 自动检测 Installer 身份(单 p12 方案,#462)。
- name: Sign pkg (macOS only)
if: runner.os == 'macOS' && env.MACOS_SIGNING_P12_BASE64 != ''
shell: bash
run: |
PKG="$(find dist/artifacts -maxdepth 1 -name 'EMRG-*-macos-*.pkg' | head -1)"
# 不跳过(issue #1913):这一步的**主语**就是 pkg。缺席时印 "skipping" + exit 0
# 等于给一次**没有 macOS 产物**的发布盖章——release 断言按本次构建的产物集比对,
# 缺平台在它两侧同时缺席 ⇒ 整轮绿并发布为 Latest。Windows 那一步同款形状:
# 点名模式 + exit 1。`if:` 上的降级(Secret 未配)不受影响,那是另一个问题。
if [ -z "$PKG" ]; then
echo "::error::no EMRG-*-macos-*.pkg found in dist/artifacts — this step's subject is the pkg, and a release missing its macOS product must not be reported as success"
exit 1
fi
INSTALLER_ID="${MACOS_INSTALLER_IDENTITY:-}"
if [ -z "$INSTALLER_ID" ]; then
INSTALLER_ID="$(security find-identity -v /tmp/ci.keychain | grep 'Developer ID Installer' | head -1 | sed -E 's/.*"([^"]+)".*/\1/')"
fi
if [ -z "$INSTALLER_ID" ]; then
echo "::error::pkg 签名需要 Developer ID Installer 证书(productsign 不接受 Application 身份)。请配置 MACOS_INSTALLER_IDENTITY + MACOS_INSTALLER_P12_BASE64(双 p12 方案),或用 security export -t identities 导出含 Installer 的 p12 到 MACOS_SIGNING_P12_BASE64(单 p12 方案)。"
exit 1
fi
productsign --sign "$INSTALLER_ID" "$PKG" "${PKG}.signed"
mv "${PKG}.signed" "$PKG"
# P3 验证:证书存在时签名必须成功(rant 验收:pkgutil 显示 signed by Developer ID)
pkgutil --check-signature "$PKG"
# ── pkg 公证(macOS only,P2 后半;Secret 未配则跳过降级)──
# notarytool submit --wait:轮询直至 Accepted/Invalid。
# ⚠️ 实测(run 31081477123,#475):公证结果 Invalid 时 notarytool 退出码仍为 0
# (提交处理完成 ≠ 通过),若不解析 status 字段会继续 Staple → 报 cryptic 的
# "Record not found" Error 65,掩盖真实 rejection 原因。必须显式解析并抓 log。
# --timeout 20m:设计文档 §4.4 明确要求(默认 10m,Apple 公证排队高峰可能超时)
# ⚠️ 实测(tag v0.3.8,run 36956685533,2026-10-02):submit 本身非零退出时,
# `VAR="$(cmd)"` 在 `-e` 下带着失败状态中止脚本,紧接着的 `echo "$NOTARY_OUT"`
# 来不及执行 —— 于是 `2>&1` 捕获到的 Apple 原话被丢弃,日志里只剩
# "exit code 1",与"notarytool 不存在"无法区分。故用 `|| NOTARY_RC=$?` 取回
# 退出码:仍然失败(绝不放过未公证的 pkg),但先说清是提交被拒还是公证未过。
# ⚠️ 第二条同源缺陷,同一次实测:`$SUB_ID)` 里 `)` 的首字节在 UTF-8 locale 下
# 被 bash 当成标识符字符(LC_ALL=en_US.UTF-8 实测:值为空且尾部渲染成乱码;
# LC_ALL=C 时正常)。故此处一律写 `${SUB_ID}` —— 变量引用不得紧邻非 ASCII 字节。
- name: Notarize pkg (macOS only)
if: runner.os == 'macOS' && env.APPLE_ID != '' && env.MACOS_NOTARY_APP_PASSWORD != '' && env.MACOS_NOTARY_TEAM_ID != ''
shell: bash
run: |
PKG="$(find dist/artifacts -maxdepth 1 -name 'EMRG-*-macos-*.pkg' | head -1)"
# 不跳过(issue #1913):这一步的**主语**就是 pkg。缺席时印 "skipping" + exit 0
# 等于给一次**没有 macOS 产物**的发布盖章——release 断言按本次构建的产物集比对,
# 缺平台在它两侧同时缺席 ⇒ 整轮绿并发布为 Latest。Windows 那一步同款形状:
# 点名模式 + exit 1。`if:` 上的降级(Secret 未配)不受影响,那是另一个问题。
if [ -z "$PKG" ]; then
echo "::error::no EMRG-*-macos-*.pkg found in dist/artifacts — this step's subject is the pkg, and a release missing its macOS product must not be reported as success"
exit 1
fi
# A non-zero exit from the submit has two shapes and they have different remedies,
# so the step must classify before it speaks. (1) Apple ANSWERED and refused -
# a credential verdict, never retried. (2) The exchange never completed - a lost
# connection or a timeout, where Apple never answered and no credential was judged;
# that one is transient and is retried. Measured on tag v0.3.10, run 38353839071:
# the sibling `Staple pkg` step lost its connection ~6 s into a ticket call to
# api.apple-cloudkit.com and exited 68. Before this, the sentence below called
# shape (2) "the SUBMISSION was refused" and sent the reader to check credentials
# that were never refused.
#
# The two predicates are pure bash `case` matches rather than `printf | grep`:
# under the job's `-o pipefail`, `grep -q` exiting on its first match can hand the
# writer a SIGPIPE and fail the pipeline the caller is branching on.
notary_lost_connection() {
case "$1" in
*NSURLErrorDomain*|*"network connection was lost"*|*"Code=-1005"*|*NSURLErrorTimedOut*|*"Code=-1001"*|*"Could not connect to the server"*)
return 0 ;;
esac
return 1
}
# Apple returned a submission id, so it accepted the upload - a re-send could
# double-submit, which the retry must never do.
notary_accepted_an_upload() {
case "$1" in
*'"id"'*) return 0 ;;
esac
return 1
}
NOTARY_RC=0
NOTARY_OUT=""
# One attempt is one `notarytool submit`. The bound is stated once so the message
# below can print the count the loop actually took instead of a literal that goes
# stale the moment the bound moves (`Agent.md`: a derived number is never written
# where a guard can measure it; issue #2199).
ATTEMPTS=3
# The third outcome, and the only one this step can reach where the retry is
# suppressed: Apple ACCEPTED the upload and returned a submission id, and the wait
# then lost the connection. It is neither a refusal nor the transport-exhausted
# shape, so the caller has to be able to tell it apart from both - and it must not
# be reported with the transport sentence, three of whose clauses are false here
# (one submit WAS taken, Apple DID answer, the pkg WAS uploaded).
#
# A flag beside NOTARY_RC rather than a return code: NOTARY_RC has to keep
# reporting the submit's own exit code for the message below, and the caller runs
# under the job's `-e`, where a non-zero return from `notary_submit` would abort
# the step before it could speak at all.
NOTARY_UNREAD_UPLOAD_ID=0
# How many `notarytool submit` calls this run took, carried out of the loop for the
# same reason `ATTEMPTS` is a variable: `attempt` is `local` to `notary_submit`, so
# it is gone by the time the message below runs, and a sentence that states the
# count must read it rather than assert a literal. The accepted-upload branch is
# reachable on attempt 2, not only on attempt 1 (one attempt may lose the connection
# without an id and be retried), so the count is genuinely not always one.
NOTARY_SUBMITS_TAKEN=0
notary_submit() {
local attempt
for (( attempt=1; attempt<=ATTEMPTS; attempt++ )); do
NOTARY_RC=0
NOTARY_OUT="$(xcrun notarytool submit "$PKG" \
--apple-id "$APPLE_ID" \
--password "$MACOS_NOTARY_APP_PASSWORD" \
--team-id "$MACOS_NOTARY_TEAM_ID" \
--wait --timeout 20m --output-format json 2>&1)" || NOTARY_RC=$?
echo "$NOTARY_OUT"
if [ "$NOTARY_RC" -eq 0 ]; then
return 0
fi
if ! notary_lost_connection "$NOTARY_OUT"; then
return 0
fi
if notary_accepted_an_upload "$NOTARY_OUT"; then
echo "::warning::the connection was lost after Apple returned a submission id - a credential verdict may still be readable from that submission, so it is NOT re-sent"
NOTARY_UNREAD_UPLOAD_ID=1
NOTARY_SUBMITS_TAKEN=$attempt
return 0
fi
if [ "$attempt" -lt "$ATTEMPTS" ]; then
echo "::warning::the exchange with Apple's notary service did not complete (attempt ${attempt}/${ATTEMPTS}) - retrying in $((attempt * 20))s"
sleep $((attempt * 20))
fi
done
return 0
}
notary_submit
if [ "$NOTARY_UNREAD_UPLOAD_ID" -ne 0 ]; then
# Measured on this step's own `lost_after_id` arm before this branch existed: the
# transport sentence ran here, and it told the operator that "every attempt was
# retried (3)" when exactly one was taken (zero sleeps), that "Apple never
# answered" when Apple had returned an id and "Successfully uploaded", and that
# "nothing was uploaded" when the upload was accepted - contradicting the warning
# printed directly above it, which says a verdict "may still be readable from
# that submission". Exiting here also keeps the parse below from reporting an
# unread verdict as one that failed.
echo "::error::xcrun notarytool submit exited $NOTARY_RC after Apple ACCEPTED the upload and returned a submission id (the JSON line above). This is neither a refusal nor the transport-exhausted failure: ${NOTARY_SUBMITS_TAKEN} submit attempt(s) were taken, Apple DID answer the last one, and the pkg WAS uploaded - it is NOT re-sent, because another submit would submit it twice. What was NOT reached is the verdict: the wait then lost the connection, so no credential was judged and no notarization verdict was read. Read that verdict with xcrun notarytool log <the submission id from the line above> --apple-id \"$APPLE_ID\" --team-id \"$MACOS_NOTARY_TEAM_ID\" --password <the app-specific password>, or wait for the submission to finish and then re-run this particular job. The tag need not be re-pushed."
exit 1
fi
if [ "$NOTARY_RC" -ne 0 ]; then
if notary_lost_connection "$NOTARY_OUT"; then
echo "::error::xcrun notarytool submit exited $NOTARY_RC and the exchange with Apple never completed - the connection was lost, and every attempt was retried (${ATTEMPTS}). This is a TRANSPORT failure, not a refusal and not a notarization verdict: Apple never answered, so no credential was judged and nothing was uploaded. Re-run the release run's failed jobs (gh run rerun <the run id> --failed); the tag need not be re-pushed. If you want the credentials ruled out anyway, ask Apple directly with APPLE_ID=... MACOS_NOTARY_APP_PASSWORD=... MACOS_NOTARY_TEAM_ID=... python3 scripts/check-notary-credentials.py (exit 0 the credentials work / 1 Apple refused them and prints its reply / 2 the exchange did not complete, so no verdict was reached - never a pass)."
else
echo "::error::xcrun notarytool submit exited $NOTARY_RC - the SUBMISSION was refused, so this is not a notarization verdict. Apple's own reply is the output above. Usual causes: an expired or revoked app-specific password, an Apple ID or team ID that does not match, or a Developer Program agreement that needs accepting. A verdict failure looks different: it takes minutes, exits 0, and reports status=Invalid. Reproduce it at home before re-tagging, with the same three variables this step sends: APPLE_ID=... MACOS_NOTARY_APP_PASSWORD=... MACOS_NOTARY_TEAM_ID=... python3 scripts/check-notary-credentials.py (exit 0 the credentials work / 1 Apple refused them, and prints its reply / 2 the exchange did not complete, so no verdict was reached - never a pass)."
fi
fi
# 解析提交 ID 与状态(--output-format json 输出 {"id","message","status"})
SUB_ID="$(printf '%s' "$NOTARY_OUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)"
STATUS="$(printf '%s' "$NOTARY_OUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status',''))" 2>/dev/null)"
if [ -z "$SUB_ID" ] || [ -z "$STATUS" ]; then
echo "::error::notarytool 输出解析失败(无法获取提交 ID/状态),请手动运行 notarytool submit 排查。原始输出:$NOTARY_OUT"
exit 1
fi
if [ "$STATUS" != "Accepted" ]; then
echo "::error::Apple 公证未通过(status=$STATUS, submission-id=${SUB_ID})。抓取 rejection 日志定位原因:"
xcrun notarytool log "$SUB_ID" \
--apple-id "$APPLE_ID" \
--password "$MACOS_NOTARY_APP_PASSWORD" \
--team-id "$MACOS_NOTARY_TEAM_ID" || echo "(notarytool log 拉取失败,请用上述命令手动查看)"
exit 1
fi
echo "✅ 公证通过(status=Accepted, submission-id=${SUB_ID})"
- name: Staple pkg (macOS only)
if: runner.os == 'macOS' && env.APPLE_ID != '' && env.MACOS_NOTARY_APP_PASSWORD != '' && env.MACOS_NOTARY_TEAM_ID != ''
shell: bash
run: |
PKG="$(find dist/artifacts -maxdepth 1 -name 'EMRG-*-macos-*.pkg' | head -1)"
# 不跳过(issue #1913):这一步的**主语**就是 pkg。缺席时印 "skipping" + exit 0
# 等于给一次**没有 macOS 产物**的发布盖章——release 断言按本次构建的产物集比对,
# 缺平台在它两侧同时缺席 ⇒ 整轮绿并发布为 Latest。Windows 那一步同款形状:
# 点名模式 + exit 1。`if:` 上的降级(Secret 未配)不受影响,那是另一个问题。
if [ -z "$PKG" ]; then
echo "::error::no EMRG-*-macos-*.pkg found in dist/artifacts — this step's subject is the pkg, and a release missing its macOS product must not be reported as success"
exit 1
fi
# Both stapler calls fetch/attach Apple's ticket over the network
# (api.apple-cloudkit.com), so either can fail for a reason that has nothing to do
# with the artifact. A red macOS leg skips `release` (`needs: build`) and leaves a
# bare tag, which the upgrade chain cannot see - it reads the releases API only.
# Measured 2026-10-13, run 38353839071 (tag v0.3.10): `staple` succeeded, the
# following `validate` lost the connection ~6 s in and exited 68, and the release
# was lost to a re-run. Retry the transport; a permanent failure still reddens the
# step, it just takes a bounded number of attempts (errors below in the log).
# `spctl` below is deliberately NOT retried: it is the Gatekeeper acceptance
# verdict, not ticket transport, and it is not what failed here.
stapler_retry() {
local attempt
# The bound is stated once, and every sentence below reads it - the last one
# included, because a summary printed after `done` is the sentence an operator
# triaging a lost release reads (issue #2210). A second copy is a number
# nothing measures, and it is the copy that goes stale.
# `scripts/check-retry-bounds.py` measures exactly that, over this file.
local attempts=5
for (( attempt=1; attempt<=attempts; attempt++ )); do
if "$@"; then
return 0
fi
if [ "$attempt" -lt "$attempts" ]; then
echo "::warning::$* failed (attempt ${attempt}/${attempts}) - retrying in $((attempt * 15))s"
sleep $((attempt * 15))
fi
done
echo "::error::$* failed on all ${attempts} attempts - the last error is above"
return 1
}
stapler_retry xcrun stapler staple "$PKG"
stapler_retry xcrun stapler validate "$PKG"
# P3 终验(rant 验收 + 设计文档 §5.1):spctl 确认 Gatekeeper 接受
# (签名 + 公证 + staple 全链路的最终裁决;期望输出 "accepted")
# ⚠️ --type install 必需(#477 教训):spctl 默认类型为 execute,
# 对 pkg 评估返回 "no usable signature"(exit 3)——即使 pkg 已用
# Developer ID Installer 签名 + 公证 Accepted + staple 成功。
# 实测:xcrun stapler staple/validate 均 work,pkgutil --check-signature
# 显示 "signed by a developer certificate issued by Apple for distribution",
# 公证 status=Accepted(run 31082935194),但 spctl -a -vv 报
# "rejected, source=no usable signature"。加 --type install 后正确评估 pkg。
spctl -a -vv --type install "$PKG"
# ── 冒烟(§12,临时 HOME + 无 Python runner)────────────
- name: Smoke test (R42 temp HOME)
shell: bash
run: bash packaging/smoke-test.sh dist/runtime
- name: Smoke offline (Linux only, R96)
if: runner.os == 'Linux'
shell: bash
run: |
sudo iptables -A OUTPUT -p tcp --dport 443 -j DROP || true
sudo iptables -A OUTPUT -p tcp --dport 80 -j DROP || true
bash packaging/smoke-test.sh dist/runtime || true
sudo iptables -D OUTPUT -p tcp --dport 443 -j DROP || true
sudo iptables -D OUTPUT -p tcp --dport 80 -j DROP || true
# ── 产物上传 ─────────────────────────────────────────────
- name: Upload artifacts
uses: actions/upload-artifact@v5
with:
name: emrg-${{ matrix.os }}-${{ matrix.arch }}
path: |
dist/artifacts/*.pkg
dist/artifacts/*.exe
dist/artifacts/*.AppImage
dist/artifacts/*.zsync
dist/artifacts/*.tar.gz
dist/artifacts/*.zip
dist/artifacts/*.run
release:
needs: build
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
# R115: GITHUB_TOKEN 默认无 contents:write → softprops/action-gh-release
# 创建 release 403(Resource not accessible by integration)。
permissions:
contents: write
steps:
# The job's only input until now was the downloaded artifact set; this assertion
# needs the *tool*, so the job checks the tree out. The script is stdlib-only
# (argparse/json/plistlib/struct/tarfile), so the runner's own python3 is enough --
# no `uv sync`, and no dependency on the runtime the release is shipping.
#
# ⚠️ The order is load-bearing, not cosmetic: `actions/checkout`'s default
# `clean: true` runs `git clean -ffdx` over the workspace, and `artifacts/` is
# untracked -- a checkout placed **after** `download-artifact` would delete the
# files this step exists to read, and the assertion would report "no artifacts".
- uses: actions/checkout@v5
- uses: actions/download-artifact@v5
with:
path: artifacts
merge-multiple: true
# ── 版本断言:读产物自己声明的版本,不读源码 ─────────────────────
# 发布链把版本断言了三次,没有一次在产物内部:`tests/test_version_sync.py` 比的是
# 八个**源码**声明,`check-release-tag.py` 比的是 tag 与**被 tag 的那棵树**,
# `check-release-published.py` 读的是 release 的**元数据**(draft/prerelease/资产
# **名字**)—— 它从不打开任何一个资产。所以一个装着旧版本的产物能过全部三道。
# 而代价是有名字的:`build-runtime.sh:141` 写的 `version.txt` 来自**构建的树**,
# `UpgradeManager.tick` 拿它与 `target.lstrip("v")` 比 —— 名字与产物不一致的 release
# 会让每个装了它的宿主每 tick 重跑一次升级会话,且后续版本修不好它。
# 这一步跑在 **release 创建之前**:一次不匹配的发布一旦上线就是宿主侧的故障,
# 而这里失败只是重跑一次构建。主语是本次下载的产物集,ubuntu 腿能读的那件是
# Linux 的 runtime 归档(stdlib `tarfile`,无需解包工具);读不出任何声明就是失败,
# 不是跳过 —— 产物集里没有 version.txt 正是这条断言要报告的状态。
- name: Assert the artifacts declare the version the tag names
shell: bash
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#v}"
# nullglob, for the reason the publish check below already gives: without it an
# unmatched glob stays a literal and every comparison below would run against a
# file that does not exist.
shopt -s nullglob
candidates=(artifacts/EMRG-*-linux-*.tar.gz)
shopt -u nullglob
if [ "${#candidates[@]}" -eq 0 ]; then
echo "::error::no EMRG-*-linux-*.tar.gz among the downloaded artifacts — this step's subject is an artifact that declares a version, and a release whose artifacts cannot be read must not be reported as success"
exit 1
fi
echo "asserting version ${version} (from tag ${GITHUB_REF_NAME}) inside ${#candidates[@]} artifact(s)"
rc=0
python3 scripts/check-release-artifact-version.py "$version" "${candidates[@]}" || rc=$?
if [ "$rc" -eq 1 ]; then
echo "::error::an artifact declares a version other than ${version} — the release name and its product disagree, and every host that installs it re-triggers the upgrade session forever. The values are printed above."
elif [ "$rc" -eq 2 ]; then
echo "::error::no artifact's version could be read — 'could not measure' is not a pass: check that the Linux leg's runtime archive carries runtime/version.txt"
elif [ "$rc" -ne 0 ]; then
echo "::error::scripts/check-release-artifact-version.py exited ${rc}, which is neither this tool's fault code (1) nor its unmeasurable one (2) — the tool itself failed, so nothing was read about the artifacts."
fi
exit "$rc"
# ── 提示词断言:产物里的那份是否就是这棵树的 ─────────────────────
# 上一步读的是产物**声明的版本**;这一条读的是产物**携带的内容**。发布链此前只
# 有前者,于是一个用旧树构建的产物能通过全部三道版本断言,却把**上一版提示词**
# 交给宿主 —— 同一个缺陷上一层:名字与产物一致,产物与源码不一致。
# 提示词不存在 `app.asar` 里(那只是 GUI:`emrg/gui/package.json` 的 `build.files`),
# 服务端由 `packaging/build-runtime.sh` 复制进 `dist/runtime`,所以它随
# `runtime/source/emrg/server/prompts/system.j2` 一起进归档。实测 v0.3.21 的 Linux
# 归档:只有这一个成员,24,558 字符,正是 #2397 改写前的那份。
# 同样跑在 **release 创建之前**:产品一旦发布,宿主装到的就是旧文本。
# 只有 Linux 归档是 ubuntu 腿能读的:`.pkg` / `.exe` / AppImage 都是本工具不解的
# 容器,它会以 rc 2 说出解开它们的命令,而不是把"读不到"报成"一样"。
- name: Assert the artifacts carry this tree's agent prompt
shell: bash
run: |
set -euo pipefail
# nullglob for the reason the two version steps above give.
shopt -s nullglob
candidates=(artifacts/EMRG-*-linux-*.tar.gz)
shopt -u nullglob
if [ "${#candidates[@]}" -eq 0 ]; then
echo "::error::no EMRG-*-linux-*.tar.gz among the downloaded artifacts — this step's subject is a product that carries the agent prompt, and a release whose product cannot be read must not be reported as success"
exit 1
fi
rc=0
python3 scripts/check-release-prompt.py "${candidates[@]}" || rc=$?
if [ "$rc" -eq 1 ]; then
echo "::error::an artifact carries a different emrg/server/prompts/system.j2 than this tree — the installer would hand a host the previous prompt while the release names the new one. Rebuild the product from the tagged tree. Both character counts and the first differing line are printed above."
elif [ "$rc" -eq 2 ]; then
echo "::error::no artifact's prompt could be read — 'could not measure' is not a pass: check that the Linux leg's runtime archive carries runtime/source/emrg/server/prompts/system.j2"
elif [ "$rc" -ne 0 ]; then
echo "::error::scripts/check-release-prompt.py exited ${rc}, which is neither this tool's fault code (1) nor its unmeasurable one (2) — the tool itself failed, so nothing was read about the artifacts."
fi
exit "$rc"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: artifacts/*
generate_release_notes: true
# 发布的**终态**是发布的一部分,而它此前只活在 `Agent.md` §Releasing 第 3 步的
# 散文里 —— 靠人(或 agent)手工 `gh release view` 核对。
# 2026-09-23 的 v0.3.1 实测:softprops 在 9 个产物里传完 8 个之后被对端掐断
# (`other side closed`),release 停在 **draft** 且少一个产物;run 是红的,
# 但「红」本身没说清「修好」长什么样,恢复只能 `gh run rerun --failed`
# (重下约 1GB 再传一遍),而确认恢复成功靠的是手工再跑一次 `gh release view`。
# 更该防的是它本可以**绿着留下一个未发布的 release**:`draft`/`prerelease`
# 只在**建** release 时套用,对已存在的 release 走 update。draft 的 release 对
# `releases/latest` 不可见,自动升级链因此看不到它 —— 而 CI 不会说一个字。
# 所以终态由 API 断言,不由步骤自己的退出码断言:未发布 / 少产物 / 不是 Latest
# 三者任一成立即红。判别信号刻意各自独立,任一条单独失效不掩盖另外两条。
#
# ⚠️ `if: !cancelled()` 不是装饰,是这条步骤的**存在前提**:没有显式 `if:` 时
# 步骤继承隐式 `success()`,于是在**上一步失败时被跳过**——而「创建 release 失败」
# 正是它要报告的那个状态(v0.3.1 的 `other side closed`:release 停在 draft、
# 少一个产物,而这条检查从未运行)。立论的一个检查如果只在一切正常时才跑,
# 就无法报告它为之而写的失败。
# 选 `!cancelled()` 而非 `always()`:后者在**取消**的运行里也执行,那时下载与上传
# 都停在中途,这条检查会把「release 不存在」报成红——为一个被取消的构建制造假红。
- name: Confirm the release is published, complete, and Latest
if: ${{ !cancelled() }}
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
tag="${GITHUB_REF_NAME}"
# 期望的产物集合从**本次刚下载的那些文件**推出,不写死数量:构建矩阵增删
# 平台时这条断言随之前进,不会腐朽(本仓库的既定约定)。用 glob 而非 `ls`:
# 产物名可能含非字母数字(shellcheck SC2012),且 `ls` 在空目录里把失败也
# 报成成功,空集合必须在这里被认出来,否则下面每条比较都成了空对空。
shopt -s nullglob
found=(artifacts/*)
shopt -u nullglob
if [ "${#found[@]}" -eq 0 ]; then
echo "::error::no artifacts were downloaded — every comparison below would be vacuous"
exit 1
fi
expected="$(printf '%s\n' "${found[@]#artifacts/}" | sort)"
n="${#found[@]}"
api() { gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${tag}" --jq "$1"; }
exists=""
# The bound is stated once and the message reads it (scripts/check-retry-bounds.py):
# this step polls for a release the previous job creates, so widening the poll
# must move what the log says, not leave a second copy behind (issue #2210).
attempts=6
for (( attempt=1; attempt<=attempts; attempt++ )); do
exists="$(api .tag_name 2>/dev/null || true)"
[ -n "$exists" ] && break
echo "the release for ${tag} is not readable yet (attempt ${attempt}/${attempts})"
sleep 10
done
if [ -z "$exists" ]; then
echo "::error::no release exists for tag ${tag} — the action created nothing"
exit 1
fi
draft="$(api .draft)"
if [ "$draft" != "false" ]; then
echo "::error::the release for ${tag} is still a DRAFT (draft=${draft}) — it is invisible to releases/latest, so no host can upgrade to it"
exit 1
fi
prerelease="$(api .prerelease)"
if [ "$prerelease" != "false" ]; then
echo "::error::the release for ${tag} is marked PRERELEASE (prerelease=${prerelease})"
exit 1
fi
got="$(api '.assets[].name' | sort)"
if [ "$got" != "$expected" ]; then
echo "this run built ${n} artifact(s):"; printf '%s\n' "$expected"
echo "the release carries:"; printf '%s\n' "$got"
echo "::error::the release for ${tag} does not carry the artifact set this run built"
exit 1
fi
echo "release ${tag}: published, not prerelease, ${n} asset(s) — matches the build"
# Latest 只在本 tag 是版本序最高的那个已发布 release 时才断言:重跑(或重打)
# 一个旧 tag 是允许的,那时 `releases/latest` 指向更新的版本并不代表这次失败。
# `--paginate`(外审提出的那一半,2026-09-23 采纳):这个端点按**创建时间**倒序
# 返回,而下面按**版本序**取最大——不分页时只读最新的一页(100 个),两者一致
# 就成了「创建序与版本序在这 100 个之内不得相反」这一未被写下的前提。分页后读的
# 是全量,这个前提消失。
newest="$(gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \
--paginate \
--jq '.[]|select(.draft==false and .prerelease==false)|.tag_name' | sort -V | tail -1)"
if [ "$newest" = "$tag" ]; then
latest="$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq .tag_name)"
if [ "$latest" != "$tag" ]; then
echo "::error::${tag} is the newest published release but GitHub reports Latest=${latest}"
exit 1
fi
echo "release ${tag}: Published as Latest"
else
echo "not the newest published release (newest=${newest}) — the Latest assertion is about ${newest}, so it is not made here"
fi