Repository navigation
1695 lines (1616 loc) · 88.6 KB
/
Copy pathrelease.yml
File metadata and controls
1695 lines (1616 loc) · 88.6 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
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
name: Release
# Default-branch, tag-signalled cross-platform release:
# build (unsigned) → macOS finalize (adaptive icon + inside-out Developer ID
# sign + notarize + staple + repackage updater/dmg) → collect → publish a
# GitHub Release with the Tauri updater manifest (latest.json).
#
# Required `release` environment secrets (never repository/org secrets):
# APPLE_CERTIFICATE base64 of your "Developer ID Application" .p12
# APPLE_CERTIFICATE_PASSWORD password for that .p12
# APPLE_SIGNING_IDENTITY "Developer ID Application: NAME (TEAMID)"
# KEYCHAIN_PASSWORD any throwaway password for the temp keychain
# AC_API_KEY_ID App Store Connect API key id
# AC_API_ISSUER_ID App Store Connect issuer id
# AC_API_KEY_BASE64 base64 of the AuthKey_XXXX.p8
# TAURI_SIGNING_PRIVATE_KEY updater private key (string)
# TAURI_SIGNING_PRIVATE_KEY_PASSWORD its password (empty if none)
# IMMUTABLE_RELEASES_READ_TOKEN fine-grained token with repository
# Administration: read-only
# MANAGER_R2_S3_ENDPOINT / MANAGER_R2_PROMOTION_ACCESS_KEY_ID /
# MANAGER_R2_PROMOTION_SECRET_ACCESS_KEY
# R2 S3-compatible read/write access
# vars.MANAGER_IHEP_S3_ENDPOINT / vars.MANAGER_IHEP_S3_BUCKET /
# vars.MANAGER_IHEP_S3_REGION / vars.MANAGER_IHEP_S3_PREFIX
# MANAGER_IHEP_S3_PROMOTION_ACCESS_KEY_ID /
# MANAGER_IHEP_S3_PROMOTION_SECRET_ACCESS_KEY
# IHEP S3-compatible read/write access
# Both mirror backends are required for stable publication and must already
# contain a valid latest.json baseline. R2 is the sole CAS authority and must
# enforce conditional PutObject plus HeadObject metadata round trips. IHEP is an
# unconditional single-writer follower and only needs PutObject + metadata
# round trips; promotion never relies on its ignored conditional headers.
# GitHub Immutable Releases must be enabled before any release starts; the
# workflow verifies the repository setting with IMMUTABLE_RELEASES_READ_TOKEN.
# Separate active tag rulesets must restrict refs/tags/v* creation to the named
# release publisher and forbid every update/deletion; the same read-only token
# verifies those policies and the live peeled SHA.
# Every reused asset is also pinned to the API-provided sha256 digest.
# Configure the `release` environment for the protected default branch only;
# never allow v* tags. A tagged revision may change release-source.yml, but it
# cannot claim this environment and the signal itself has no credentials.
# The old MANAGER_*_ACCESS_KEY_ID / SECRET_ACCESS_KEY names must stay deleted;
# otherwise a historical workflow revision could still perform unconditional writes.
#
# Optional Windows Authenticode signing (non-blocking until configured; see
# docs/windows-signing.md and docs/code-signing-policy.md). Provider-agnostic:
# scripts/sign-windows-authenticode.ps1 is wired as Tauri's
# bundle.windows.signCommand and dispatches on vars.WINDOWS_SIGNING_PROVIDER,
# so the main exe, the generated uninstaller, and the final installer are all
# signed in one pass during `tauri build`. win-installer-check.yml's PR-time
# NSIS build never sets the repo variable vars.WINDOWS_SIGNING_PROVIDER — a PR
# cannot select or reach a real provider here — but it does hardcode
# WINDOWS_SIGNING_PROVIDER=local-pfx with a throwaway self-signed certificate
# generated fresh in that same job, purely to prove this signCommand hook is
# actually wired up end to end (see docs/windows-signing.md). That throwaway
# certificate is never trusted by anything, so it does not change whether a
# release build here would be considered "signed" in any way that matters.
# vars.WINDOWS_SIGNING_PROVIDER "" (default/unsigned) | "esigner" (recommended,
# SSL.com eSigner CKA) | "certum" (fallback,
# Certum SimplySign) | "local-pfx" (testing)
# ESIGNER_USERNAME / ESIGNER_PASSWORD / ESIGNER_TOTP_SECRET
# SSL.com account credentials (provider=esigner)
# CERTUM_USERNAME / CERTUM_TOTP_SECRET
# Certum SimplySign credentials (provider=certum)
# vars.CERTUM_ACTION_AUDITED must be "true" before provider=certum will run
# (see docs/windows-signing.md#certum-action-audit —
# jay0lee/certum-cloud-code-sign is a third-party
# action that receives real account credentials
# and must be independently audited or vendored
# first; a deliberate second opt-in, separate
# from selecting the provider itself)
# WINDOWS_CERTIFICATE / WINDOWS_CERTIFICATE_PASSWORD
# base64 PFX + password (provider=local-pfx only;
# legacy scaffold, kept for testing/fallback)
# vars.AUTHENTICODE_REQUIRED set to "true" to fail release when a PE is
# unsigned — flip only after a real provider
# has been verified end to end (3 PE layers,
# both archs; see docs/code-signing-policy.md)
# vars.WINDOWS_TIMESTAMP_URL optional RFC3161 timestamp URL override
on:
# `workflow_run` always loads this credentialed workflow from the repository
# default branch. Never put credentials in release-source.yml: tag-triggered
# workflow definitions are loaded from the tagged commit.
workflow_run:
workflows: ["Release source"]
types: [completed]
workflow_dispatch:
inputs:
target_tag:
description: "Existing vX.Y.Z release to republish through the current protected workflow"
required: true
type: string
allow_mirror_downgrade:
description: "Emergency only: allow this tag to replace a newer mirror latest.json"
required: false
default: false
type: boolean
mirror_downgrade_reason:
description: "Required audit reason when emergency mirror downgrade is enabled"
required: false
default: ""
type: string
permissions:
contents: read
env:
AUTHORIZED_RELEASE_ACTOR_LOGIN: Wangnov
AUTHORIZED_RELEASE_ACTOR_ID: "48670012"
RELEASE_TAG: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || inputs.target_tag }}
RELEASE_SOURCE_SHA: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || '' }}
TRUSTED_RELEASE_INVOCATION: ${{ github.event_name == 'workflow_run' && 'tag-signal' || 'manual-recovery' }}
# Serialize every credentialed mirror writer through one release lane. This is a
# correctness boundary for the unconditional IHEP follower, not an optimization.
# R2 CAS plus pre/post ownership fencing handles a losing or accidentally
# overlapping writer, but there is no cross-provider atomic transaction.
concurrency:
group: release-latest-${{ github.repository }}
cancel-in-progress: false
queue: max
jobs:
# This is the only path from the unprivileged tag signal to credentialed jobs.
# It deliberately has no checkout, environment, write permission, or secrets.
# Every field is copied through the environment so event data is never
# interpolated into shell source.
preflight:
runs-on: ubuntu-latest
permissions: {}
outputs:
release_tag: ${{ steps.validate.outputs.release_tag }}
release_source_sha: ${{ steps.validate.outputs.release_source_sha }}
trusted_invocation: ${{ steps.validate.outputs.trusted_invocation }}
steps:
- name: Validate release invocation without secrets
id: validate
shell: bash
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
CURRENT_TRIGGERING_ACTOR_LOGIN: ${{ github.triggering_actor }}
DISPATCH_REF: ${{ github.ref }}
DISPATCH_ACTOR_LOGIN: ${{ github.actor }}
DISPATCH_ACTOR_ID: ${{ github.actor_id }}
DISPATCH_TRIGGERING_ACTOR_LOGIN: ${{ github.triggering_actor }}
EVENT_NAME: ${{ github.event_name }}
REQUESTED_TARGET_TAG: ${{ inputs.target_tag || '' }}
UPSTREAM_ACTOR_LOGIN: ${{ github.event.workflow_run.actor.login || '' }}
UPSTREAM_ACTOR_ID: ${{ github.event.workflow_run.actor.id || '' }}
UPSTREAM_CONCLUSION: ${{ github.event.workflow_run.conclusion || '' }}
UPSTREAM_EVENT: ${{ github.event.workflow_run.event || '' }}
UPSTREAM_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch || '' }}
UPSTREAM_HEAD_REPOSITORY: ${{ github.event.workflow_run.head_repository.full_name || '' }}
UPSTREAM_HEAD_SHA: ${{ github.event.workflow_run.head_sha || '' }}
UPSTREAM_PATH: ${{ github.event.workflow_run.path || '' }}
UPSTREAM_REPOSITORY: ${{ github.event.workflow_run.repository.full_name || '' }}
UPSTREAM_TRIGGERING_ACTOR_LOGIN: ${{ github.event.workflow_run.triggering_actor.login || '' }}
UPSTREAM_TRIGGERING_ACTOR_ID: ${{ github.event.workflow_run.triggering_actor.id || '' }}
UPSTREAM_WORKFLOW_NAME: ${{ github.event.workflow_run.name || '' }}
run: |
set -euo pipefail
[[ "$CURRENT_TRIGGERING_ACTOR_LOGIN" == "$AUTHORIZED_RELEASE_ACTOR_LOGIN" ]] || {
echo "::error::release workflow rerun actor is not the authorized publisher"
exit 1
}
if [[ "$EVENT_NAME" == "workflow_run" ]]; then
[[ "$UPSTREAM_WORKFLOW_NAME" == "Release source" ]] || {
echo "::error::unexpected upstream workflow: $UPSTREAM_WORKFLOW_NAME"
exit 1
}
[[ "$UPSTREAM_PATH" == ".github/workflows/release-source.yml" ]] || {
echo "::error::unexpected upstream workflow path: $UPSTREAM_PATH"
exit 1
}
[[ "$UPSTREAM_EVENT" == "push" && "$UPSTREAM_CONCLUSION" == "success" ]] || {
echo "::error::release source must be a successful push workflow"
exit 1
}
[[ "$UPSTREAM_REPOSITORY" == "$GITHUB_REPOSITORY" && "$UPSTREAM_HEAD_REPOSITORY" == "$GITHUB_REPOSITORY" ]] || {
echo "::error::release source must originate in $GITHUB_REPOSITORY"
exit 1
}
[[ "$UPSTREAM_ACTOR_LOGIN" == "$AUTHORIZED_RELEASE_ACTOR_LOGIN" && "$UPSTREAM_ACTOR_ID" == "$AUTHORIZED_RELEASE_ACTOR_ID" ]] || {
echo "::error::release source actor is not the authorized publisher"
exit 1
}
[[ "$UPSTREAM_TRIGGERING_ACTOR_LOGIN" == "$AUTHORIZED_RELEASE_ACTOR_LOGIN" && "$UPSTREAM_TRIGGERING_ACTOR_ID" == "$AUTHORIZED_RELEASE_ACTOR_ID" ]] || {
echo "::error::release source rerun actor is not the authorized publisher"
exit 1
}
[[ "$UPSTREAM_HEAD_SHA" =~ ^[0-9a-f]{40}$ ]] || {
echo "::error::release source head_sha must be a full commit SHA"
exit 1
}
RELEASE_TAG="$UPSTREAM_HEAD_BRANCH"
RELEASE_SOURCE_SHA="$UPSTREAM_HEAD_SHA"
TRUSTED_RELEASE_INVOCATION="tag-signal"
elif [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
[[ "$DISPATCH_REF" == "refs/heads/$DEFAULT_BRANCH" ]] || {
echo "::error::workflow_dispatch must run from refs/heads/$DEFAULT_BRANCH, not $DISPATCH_REF"
exit 1
}
[[ "$DISPATCH_ACTOR_LOGIN" == "$AUTHORIZED_RELEASE_ACTOR_LOGIN" && "$DISPATCH_ACTOR_ID" == "$AUTHORIZED_RELEASE_ACTOR_ID" && "$DISPATCH_TRIGGERING_ACTOR_LOGIN" == "$AUTHORIZED_RELEASE_ACTOR_LOGIN" ]] || {
echo "::error::workflow_dispatch actor is not the authorized publisher"
exit 1
}
[[ -n "$REQUESTED_TARGET_TAG" ]] || {
echo "::error::workflow_dispatch requires a non-empty target_tag"
exit 1
}
RELEASE_TAG="$REQUESTED_TARGET_TAG"
RELEASE_SOURCE_SHA=""
TRUSTED_RELEASE_INVOCATION="manual-recovery"
else
echo "::error::unsupported release event: $EVENT_NAME"
exit 1
fi
if [[ ! "$RELEASE_TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then
echo "::error::release tag must be a semantic vX.Y.Z tag: $RELEASE_TAG"
exit 1
fi
printf 'release_tag=%s\n' "$RELEASE_TAG" >> "$GITHUB_OUTPUT"
printf 'release_source_sha=%s\n' "$RELEASE_SOURCE_SHA" >> "$GITHUB_OUTPUT"
printf 'trusted_invocation=%s\n' "$TRUSTED_RELEASE_INVOCATION" >> "$GITHUB_OUTPUT"
prepare:
needs: preflight
if: ${{ needs.preflight.result == 'success' }}
runs-on: ubuntu-latest
environment: release
permissions:
contents: read
outputs:
release_reusable: ${{ steps.lookup.outputs.release_reusable }}
release_asset_digests: ${{ steps.lookup.outputs.release_asset_digests }}
release_source_sha: ${{ steps.tag_source.outputs.release_source_sha }}
steps:
- name: Authorize credentialed job rerun actor
shell: bash
env:
CURRENT_TRIGGERING_ACTOR_LOGIN: ${{ github.triggering_actor }}
run: |
set -euo pipefail
[[ "$CURRENT_TRIGGERING_ACTOR_LOGIN" == "$AUTHORIZED_RELEASE_ACTOR_LOGIN" ]] || {
echo "::error::credentialed release job rerun actor is not the authorized publisher"
exit 1
}
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24
# Resolve the live tag through the API before any tag checkout. A
# workflow_run must still point at the exact SHA carried by the successful
# signal; an authorized manual recovery may resolve the immutable tag here.
- name: Resolve protected immutable release tag
id: tag_source
env:
ALLOW_RELEASE_SHA_RESOLUTION: ${{ needs.preflight.outputs.trusted_invocation == 'manual-recovery' && '1' || '0' }}
GH_TOKEN: ${{ secrets.IMMUTABLE_RELEASES_READ_TOKEN }}
RELEASE_SOURCE_SHA: ${{ needs.preflight.outputs.release_source_sha }}
run: node scripts/check-release-tag-protection.mjs "$RELEASE_TAG" "$RELEASE_SOURCE_SHA"
# The root checkout contains only the trusted default-branch workflow and
# release scripts. Check out application source separately by the already
# verified commit SHA, never by a mutable tag name.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ steps.tag_source.outputs.release_source_sha }}
path: release-source
persist-credentials: false
fetch-depth: 0
- name: Match release tag to every application version declaration
shell: bash
env:
EXPECTED_RELEASE_SOURCE_SHA: ${{ steps.tag_source.outputs.release_source_sha }}
run: |
set -euo pipefail
node scripts/check-release-version.mjs source "$RELEASE_TAG" release-source
release_source_sha="$(git -C release-source rev-parse HEAD)"
if [[ "$release_source_sha" != "$EXPECTED_RELEASE_SOURCE_SHA" ]]; then
echo "::error::release checkout differs from verified commit $EXPECTED_RELEASE_SOURCE_SHA"
exit 1
fi
echo "RELEASE_SOURCE_SHA=$release_source_sha" >> "$GITHUB_ENV"
- name: Require release source merged into live default branch
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
VERIFIED_RELEASE_SOURCE_SHA: ${{ steps.tag_source.outputs.release_source_sha }}
run: bash scripts/check-release-source-ancestor.sh release-source "$VERIFIED_RELEASE_SOURCE_SHA" "$DEFAULT_BRANCH"
- name: Require GitHub Immutable Releases
env:
GH_TOKEN: ${{ secrets.IMMUTABLE_RELEASES_READ_TOKEN }}
run: node scripts/check-immutable-releases.mjs
- name: Check for an existing immutable release
id: lookup
shell: bash
env:
GH_TOKEN: ${{ github.token }}
REQUESTED_TARGET_TAG: ${{ inputs.target_tag || '' }}
run: |
set -euo pipefail
if [[ ! "$RELEASE_TAG" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then
echo "::error::release tag must be a semantic vX.Y.Z tag: $RELEASE_TAG"
exit 1
fi
lookup_error="$RUNNER_TEMP/release-lookup-error.txt"
release_json="$RUNNER_TEMP/release.json"
if gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" >"$release_json" 2>"$lookup_error"; then
REQUESTED_TARGET_TAG="$REQUESTED_TARGET_TAG" \
node scripts/check-release-reuse.mjs "$RELEASE_TAG" "$release_json"
elif grep -q 'HTTP 404' "$lookup_error"; then
if [[ -n "$REQUESTED_TARGET_TAG" ]]; then
echo "::error::target_tag $REQUESTED_TARGET_TAG must already exist as a GitHub Release"
exit 1
fi
echo "release_reusable=false" >> "$GITHUB_OUTPUT"
else
cat "$lookup_error" >&2
echo "::error::could not determine whether release $RELEASE_TAG already exists"
exit 1
fi
build:
needs: [preflight, prepare]
if: ${{ needs.prepare.result == 'success' && needs.prepare.outputs.release_reusable != 'true' && needs.preflight.outputs.trusted_invocation == 'tag-signal' }}
# All signing secrets live only in the default-branch-restricted release
# environment. Never duplicate them as repository or organization secrets.
environment: release
env:
RELEASE_SOURCE_SHA: ${{ needs.prepare.outputs.release_source_sha }}
permissions:
contents: read
defaults:
run:
working-directory: release-source
strategy:
fail-fast: false
matrix:
include:
- { platform: macos-latest, target: aarch64-apple-darwin, os: macos }
# Intel build on GitHub's current x86_64 image — macos-13 retired
# 2025-12; macos-15-intel is the last Intel image (until ~2027-08).
- { platform: macos-15-intel, target: x86_64-apple-darwin, os: macos }
- {
platform: windows-latest,
target: x86_64-pc-windows-msvc,
os: windows,
}
- {
platform: windows-latest,
target: aarch64-pc-windows-msvc,
os: windows,
}
runs-on: ${{ matrix.platform }}
steps:
- name: Authorize credentialed job rerun actor
shell: bash
# Job-level defaults point later build commands at release-source, but
# that checkout does not exist until the following actions run.
working-directory: ${{ github.workspace }}
env:
CURRENT_TRIGGERING_ACTOR_LOGIN: ${{ github.triggering_actor }}
run: |
set -euo pipefail
[[ "$CURRENT_TRIGGERING_ACTOR_LOGIN" == "$AUTHORIZED_RELEASE_ACTOR_LOGIN" ]] || {
echo "::error::credentialed release job rerun actor is not the authorized publisher"
exit 1
}
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.prepare.outputs.release_source_sha }}
path: release-source
persist-credentials: false
fetch-depth: 0
# A successful prepare may belong to an earlier attempt. Re-fetch the live
# default branch before executing application-controlled build code or
# exposing signing credentials to later steps.
- name: Re-check release source before build
shell: bash
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
set -euo pipefail
[[ "$(git rev-parse HEAD)" == "$RELEASE_SOURCE_SHA" ]] || {
echo "::error::release checkout differs from verified commit $RELEASE_SOURCE_SHA"
exit 1
}
node ../scripts/check-release-version.mjs source "$RELEASE_TAG" .
bash ../scripts/check-release-source-ancestor.sh . "$RELEASE_SOURCE_SHA" "$DEFAULT_BRANCH"
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24
cache: npm
cache-dependency-path: release-source/package-lock.json
- uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
workspaces: release-source -> target
- run: npm ci
# ── macOS: vendor the Sparkle BinaryDelta helper BEFORE the build so it
# ships in Resources/. Without it the manager can't apply deltas and
# every macOS update silently falls back to the full-package download
# (the 0.1.x releases up to 0.1.12 shipped without it). The script
# pins Sparkle 2.9.1 by SHA-256; see scripts/vendor-binary-delta.sh.
- name: Vendor Sparkle BinaryDelta (macOS delta updates)
if: matrix.os == 'macos'
run: bash scripts/vendor-binary-delta.sh
# ── macOS: import the Developer ID cert into a throwaway keychain ──────
- name: Import Apple Developer ID certificate
if: matrix.os == 'macos'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
KC="$RUNNER_TEMP/build.keychain-db"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KC"
security set-keychain-settings -lut 21600 "$KC"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KC"
echo "$APPLE_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/cert.p12"
security import "$RUNNER_TEMP/cert.p12" -k "$KC" -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KC"
security list-keychains -d user -s "$KC" $(security list-keychains -d user | sed s/\"//g)
# ── Windows: assert the manual-approval gate before any signing secret is used ─
# docs/code-signing-policy.md requires per-request human approval
# (a required-reviewers rule on the `release` Environment) before an
# automated cloud-HSM login may sign anything. That rule is repository
# configuration, not workflow code, so without this step forgetting to
# configure it would silently sign with a stored credential and no
# approval. Only runs when a cloud provider is selected — never in
# today's unsigned default. Best effort by construction, and likely
# INERT with the default token: GITHUB_TOKEN has no `environments`
# permission, so this call is expected to fail and only warn (rule
# not verified) - it is a tripwire, not the control. The real control
# is the manual checklist in docs/windows-signing.md (step 2). Only a
# readable answer with no required_reviewers rule (including no
# protection rules at all) is a hard failure.
- name: Assert release environment requires reviewers (cloud signing)
if: matrix.os == 'windows' && (vars.WINDOWS_SIGNING_PROVIDER == 'esigner' || vars.WINDOWS_SIGNING_PROVIDER == 'certum')
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
WINDOWS_SIGNING_PROVIDER: ${{ vars.WINDOWS_SIGNING_PROVIDER }}
run: |
$raw = & gh api "repos/$env:GITHUB_REPOSITORY/environments/release" 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Host "::warning::Could not read the release environment's protection rules with the workflow token, so the required-reviewers gate is NOT verified by this run. Confirm it manually (docs/windows-signing.md, step 2)."
return
}
$rules = @((($raw | Out-String) | ConvertFrom-Json).protection_rules)
if (-not ($rules | Where-Object { $_.type -eq "required_reviewers" })) {
Write-Host "::error::WINDOWS_SIGNING_PROVIDER=$env:WINDOWS_SIGNING_PROVIDER but the release environment has no required-reviewers rule (rules seen: $($rules.Count)). docs/code-signing-policy.md requires per-request human approval before a cloud signing credential is used; add a required reviewer (Settings > Environments > release) first."
exit 1
}
Write-Host "release environment has a required-reviewers rule."
# ── Windows: provision the eSigner CKA certificate (recommended provider) ─
# Only runs when vars.WINDOWS_SIGNING_PROVIDER == 'esigner'. No-op
# (and no secrets touched) for every other value, including the
# default empty string — matching today's unsigned release exactly.
# Authenticates to SSL.com and installs a CNG-backed certificate into
# the runner's CurrentUser certificate store *before* `tauri build`
# runs, so the signCommand hook (scripts/sign-windows-authenticode.ps1)
# can sign with plain signtool.exe. Only the resulting certificate
# thumbprint — not a secret — is exported to WINDOWS_SIGNING_THUMBPRINT.
# Flags follow SSL.com's published eSigner CKA CI/CD guide; validate
# against a real SSL.com account before relying on this in production
# (see docs/windows-signing.md).
# The downloaded release archive's SHA-256 is pinned and verified
# before extraction: this step runs in the credentialed `release`
# environment (ESIGNER_* secrets), so a replaced/compromised GitHub
# release asset must not be able to execute inside it unnoticed.
# Download/verify/extract/rename is factored into
# scripts/resolve-esigner-cka-installer.ps1 (shared with the
# secret-free CI dry run in win-installer-check.yml) — see that
# script's header for why the extracted installer must be renamed.
# Update its -Version / -ExpectedSha256 defaults together in a
# reviewed PR when bumping the eSigner CKA release.
- name: Provision eSigner CKA certificate
if: matrix.os == 'windows' && vars.WINDOWS_SIGNING_PROVIDER == 'esigner'
shell: pwsh
env:
ESIGNER_USERNAME: ${{ secrets.ESIGNER_USERNAME }}
ESIGNER_PASSWORD: ${{ secrets.ESIGNER_PASSWORD }}
ESIGNER_TOTP_SECRET: ${{ secrets.ESIGNER_TOTP_SECRET }}
run: |
$ErrorActionPreference = "Stop"
if ([string]::IsNullOrWhiteSpace($env:ESIGNER_USERNAME) -or [string]::IsNullOrWhiteSpace($env:ESIGNER_PASSWORD) -or [string]::IsNullOrWhiteSpace($env:ESIGNER_TOTP_SECRET)) {
Write-Host "::error::WINDOWS_SIGNING_PROVIDER=esigner but ESIGNER_USERNAME/ESIGNER_PASSWORD/ESIGNER_TOTP_SECRET are not fully configured on the release environment"
exit 1
}
$installDir = Join-Path $env:RUNNER_TEMP "eSignerCKA"
$installer = & ./scripts/resolve-esigner-cka-installer.ps1 -DestinationDir $installDir -Stage "esigner-provision"
& $installer /CURRENTUSER /VERYSILENT /SUPPRESSMSGBOXES "/DIR=$installDir" | Out-Null
$tool = Join-Path $installDir "eSignerCKATool.exe"
$masterKey = Join-Path $installDir "master.key"
& $tool config -mode "product" -user "$env:ESIGNER_USERNAME" -pass "$env:ESIGNER_PASSWORD" -totp "$env:ESIGNER_TOTP_SECRET" -key "$masterKey" -r
& $tool unload
# Identify the certificate THIS step loads by diffing the store
# before/after `load`, rather than blindly taking the first
# CurrentUser\My code-signing cert — a runner (self-hosted, or a
# future change that provisions another cert earlier in the job)
# could otherwise have an unrelated code-signing certificate
# already present, silently signing the release with the wrong
# publisher.
$beforeThumbprints = @(Get-ChildItem Cert:\CurrentUser\My -CodeSigningCert -ErrorAction SilentlyContinue | ForEach-Object Thumbprint)
& $tool load
$afterCerts = @(Get-ChildItem Cert:\CurrentUser\My -CodeSigningCert -ErrorAction SilentlyContinue)
$newCerts = @($afterCerts | Where-Object { $beforeThumbprints -notcontains $_.Thumbprint })
if ($newCerts.Count -eq 0) {
Write-Host "::error::eSigner CKA load did not add a new code-signing certificate to CurrentUser\My (pre-existing: $($beforeThumbprints -join ', '))"
exit 1
}
if ($newCerts.Count -gt 1) {
Write-Host "::error::eSigner CKA load added more than one code-signing certificate to CurrentUser\My; cannot unambiguously pick one ($((($newCerts | ForEach-Object Thumbprint)) -join ', '))"
exit 1
}
$cert = $newCerts[0]
Write-Host "eSigner CKA certificate loaded: thumbprint=$($cert.Thumbprint) subject=$($cert.Subject)"
"WINDOWS_SIGNING_THUMBPRINT=$($cert.Thumbprint)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# ── Windows: provision the Certum SimplySign certificate (fallback) ────
# Only runs when vars.WINDOWS_SIGNING_PROVIDER == 'certum'. Uses the
# community-maintained, SHA-pinned jay0lee/certum-cloud-code-sign
# action to authenticate to SimplySign and install the certificate.
# Only its cert-thumbprint output (not a secret) feeds the signCommand
# hook. See docs/windows-signing.md for setup and the risk note about
# depending on a third-party action.
#
# That action is a single-author, unaudited-by-us repository that
# GUI-automates the SimplySign Desktop client via its own
# install/auth/verify scripts, and it receives the real Certum
# account credentials (CERTUM_USERNAME + CERTUM_TOTP_SECRET), not a
# scoped signing token. SHA-pinning stops the pinned commit from
# changing under us, but it does not substitute for actually
# reading that commit's scripts. Require an explicit, separate
# opt-in (vars.CERTUM_ACTION_AUDITED=true) so enabling the certum
# fallback is a deliberate decision made only after a maintainer
# has read jay0lee/certum-cloud-code-sign@a3324503 (or vendored a
# reviewed fork of it) — see docs/windows-signing.md#certum-action-audit.
- name: Require Certum action audit acknowledgment
if: matrix.os == 'windows' && vars.WINDOWS_SIGNING_PROVIDER == 'certum' && vars.CERTUM_ACTION_AUDITED != 'true'
shell: pwsh
run: |
Write-Host "::error::WINDOWS_SIGNING_PROVIDER=certum requires vars.CERTUM_ACTION_AUDITED=true. jay0lee/certum-cloud-code-sign is a third-party action that receives real Certum account credentials; read docs/windows-signing.md#certum-action-audit and audit (or vendor) that action before setting this variable."
exit 1
# GitHub 在执行 if 之前校验所有外部 action。尚未启用的 Certum action
# 不在本仓库允许列表中,直接引用也会阻止默认的未签名发布启动。
# 保留 provider 的明确错误,待供应商接入完成审核后再加入本地实现。
- name: Require reviewed local Certum provisioning
if: matrix.os == 'windows' && vars.WINDOWS_SIGNING_PROVIDER == 'certum'
shell: pwsh
run: |
Write-Host "::error::Certum 备用方案尚未接入:本仓库 action 策略禁止直接调用外部 Certum action。请先审核并接入本地证书预配实现,再启用 certum provider。"
exit 1
# ── Build (macOS: signed inside-out after; Windows: signed inline via ──
# the signCommand hook — scripts/sign-windows-authenticode.ps1 — when
# vars.WINDOWS_SIGNING_PROVIDER selects a provider; unsigned no-op
# otherwise, which is today's exact default behavior) ────────────────
- name: Build (Tauri)
shell: bash
# These are only ever read by signCommand on the Windows legs (see
# scripts/sign-windows-authenticode.ps1). Gate every value on
# matrix.os == 'windows' — not just the provider selector — so the
# code-signing PFX and its password never land in the macOS build
# process's environment, where a compromised npm/cargo build-time
# script (postinstall hook, build.rs, etc.) could read and exfiltrate
# them even though that leg never uses them. The PFX secrets are
# further limited to provider local-pfx (which signs with them) and
# to an unset/"none" provider (so the migration guard in the signing
# script can still see a leftover WINDOWS_CERTIFICATE and fail loudly);
# with esigner/certum a retained legacy PFX is never exposed to the
# build process.
env:
WINDOWS_SIGNING_PROVIDER: ${{ matrix.os == 'windows' && vars.WINDOWS_SIGNING_PROVIDER || '' }}
WINDOWS_CERTIFICATE: ${{ matrix.os == 'windows' && contains(fromJSON('["","none","local-pfx"]'), vars.WINDOWS_SIGNING_PROVIDER) && secrets.WINDOWS_CERTIFICATE || '' }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ matrix.os == 'windows' && contains(fromJSON('["","none","local-pfx"]'), vars.WINDOWS_SIGNING_PROVIDER) && secrets.WINDOWS_CERTIFICATE_PASSWORD || '' }}
WINDOWS_TIMESTAMP_URL: ${{ matrix.os == 'windows' && vars.WINDOWS_TIMESTAMP_URL || '' }}
# The signing script writes this file when signing fails (see
# Write-FailureMarker in scripts/sign-windows-authenticode.ps1) so the
# retry loop below can tell a deterministic signing failure from a
# transient toolchain download failure.
WINDOWS_SIGNING_FAILURE_MARKER: ${{ matrix.os == 'windows' && format('{0}/signing-failed.marker', runner.temp) || '' }}
# Bundling downloads toolchains on the fly (e.g. the NSIS archive from
# GitHub) which occasionally 504s and fails the whole release. Retry a
# few times so a transient download hiccup doesn't sink the run — cargo
# output is cached, so a retry mostly just re-runs the bundling step.
run: |
marker=""
if [ -n "${WINDOWS_SIGNING_FAILURE_MARKER:-}" ]; then
marker="$(cygpath -u "$WINDOWS_SIGNING_FAILURE_MARKER" 2>/dev/null || printf '%s' "$WINDOWS_SIGNING_FAILURE_MARKER")"
fi
for attempt in 1 2 3; do
[ -z "$marker" ] || rm -f "$marker"
npm run tauri build -- --target ${{ matrix.target }} && exit 0
# Authenticode signing failed: deterministic, and each repeat would
# re-run (and, with a cloud provider, re-bill) every signing call.
# Do not retry; surface the real error instead.
if [ -n "$marker" ] && [ -e "$marker" ]; then
echo "::error::tauri build failed during Windows signing (not retrying): $(cat "$marker")"
exit 1
fi
echo "::warning::tauri build attempt ${attempt} failed (often a transient NSIS/toolchain download, e.g. HTTP 504) — retrying in 20s"
sleep 20
done
echo "::error::tauri build failed after 3 attempts"
exit 1
# Regression gate: losing the helper is otherwise SILENT — updates still
# succeed, they just always download the full ~400MB package instead of
# the ~40MB delta. Fail the release loudly instead.
- name: Assert BinaryDelta inside the bundle
if: matrix.os == 'macos'
run: |
APP="$(find "target/${{ matrix.target }}/release/bundle/macos" -maxdepth 1 -name '*.app' | head -1)"
BD="$APP/Contents/Resources/resources/BinaryDelta"
[ -x "$BD" ] || { echo "::error::BinaryDelta missing or not executable at $BD — macOS delta updates would silently fall back to full downloads"; exit 1; }
arches="$(lipo -archs "$BD")"
case "$arches" in
*x86_64*arm64*|*arm64*x86_64*) echo "BinaryDelta OK ($arches): $BD" ;;
*) echo "::error::BinaryDelta is not universal (arches: $arches)"; exit 1 ;;
esac
# ── macOS: adaptive icon → sign → notarize → staple → repackage ───────
- name: Finalize macOS bundle
if: matrix.os == 'macos'
env:
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
AC_API_KEY_ID: ${{ secrets.AC_API_KEY_ID }}
AC_API_ISSUER_ID: ${{ secrets.AC_API_ISSUER_ID }}
AC_API_KEY_BASE64: ${{ secrets.AC_API_KEY_BASE64 }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
echo "$AC_API_KEY_BASE64" | base64 --decode > "$RUNNER_TEMP/AuthKey.p8"
export AC_API_KEY="$RUNNER_TEMP/AuthKey.p8"
bash scripts/finalize-macos.sh "${{ matrix.target }}"
# ── Windows: PE architecture diagnostic (especially ARM64 cross-build) ─
# windows-latest is x64; aarch64-pc-windows-msvc is cross-compiled.
# Hard-assert the main PE machine type (0xAA64 / 0x8664). The NSIS
# outer -setup.exe is an x86/x64 stub that embeds the payload, so we
# assert machine type on the app binary, not the setup wrapper.
# Cross-compilation ≠ runtime verification. See docs/windows-signing.md.
# Invoke scripts in-process (`& .\scripts\…`); nested `pwsh -File` breaks
# array parameter binding for -Path on GitHub-hosted Windows runners.
- name: Windows PE architecture diagnostic
if: matrix.os == 'windows'
shell: pwsh
run: |
$target = "${{ matrix.target }}"
$main = "target/$target/release/codex-app-manager.exe"
$setup = @(Get-ChildItem "target/$target/release/bundle/nsis/*-setup.exe" -ErrorAction SilentlyContinue)
if (-not (Test-Path -LiteralPath $main)) {
Write-Host "::error::[build] missing main PE: $main"
exit 1
}
$expect = if ($target -like "aarch64-*") { "0xAA64" } else { "0x8664" }
# Hard assert on the app binary only (setup is a host stub).
& .\scripts\windows-pe-arch.ps1 -Path $main -ExpectMachine $expect
if ($setup.Count -gt 0) {
# Report-only for the installer wrapper (no ExpectMachine).
& .\scripts\windows-pe-arch.ps1 -Path @($setup | ForEach-Object { $_.FullName })
}
if ($target -like "aarch64-*") {
Write-Host "::notice::ARM64 main PE asserted machine=0xAA64 (cross-build on x64). Full Manager installer smoke remains x64-only; native ARM64 portable MSIX extraction and cua_node module resolution run in windows-portable-msix-e2e.yml."
}
# ── Windows: verify Authenticode on the final NSIS installer ───────────
# Signing itself already happened inline during `tauri build` via the
# signCommand hook (scripts/sign-windows-authenticode.ps1), covering
# the main binary, the generated uninstaller (via NSIS
# !uninstfinalize), and the final -setup.exe — see
# src-tauri/tauri.conf.json bundle.windows.signCommand.
# Only the installer is checked here, deliberately NOT
# target/<target>/release/codex-app-manager.exe: tauri-bundler
# signs a copy of the main exe for embedding and then restores that
# on-disk file to its unsigned, unpatched original after each bundle
# type (tauri-cli 2.11.5, tauri-bundler bundle.rs, "Restore unsigned
# and unpatched binary"), so that path is NotSigned even on a fully
# signed release. The signed main exe and the uninstaller exist as
# files only once the installer is installed — the next step verifies
# them (x64). ARM64 cannot install on an x64 runner, so its main exe
# and uninstaller stay on the manual checklist in
# docs/windows-signing.md.
# Optional until vars.AUTHENTICODE_REQUIRED=true; unsigned
# (NotSigned) is expected and non-blocking while no
# WINDOWS_SIGNING_PROVIDER is configured.
# -ExpectedThumbprint pins the check to the specific certificate the
# esigner/certum provisioning step loaded and exported as
# WINDOWS_SIGNING_THUMBPRINT (see docs/code-signing-policy.md's "from
# the expected publisher" requirement) — Valid + timestamp alone
# would still pass for a trusted certificate belonging to someone
# else. Empty for local-pfx/no provider, matching today's behavior.
- name: Verify Authenticode on Windows installer
if: matrix.os == 'windows'
shell: pwsh
env:
AUTHENTICODE_MODE: ${{ vars.AUTHENTICODE_REQUIRED == 'true' && 'required' || 'optional' }}
run: |
$setup = Get-ChildItem "target/${{ matrix.target }}/release/bundle/nsis/*-setup.exe" -ErrorAction Stop
& .\scripts\verify-windows-authenticode.ps1 `
-Path @($setup.FullName) `
-Mode $env:AUTHENTICODE_MODE `
-ExpectedThumbprint $env:WINDOWS_SIGNING_THUMBPRINT `
-Stage "sign-verify"
# ── Windows (x64 only): installed main exe + uninstaller Authenticode
# gate on the real, signed release artifact ──────────────────────────
# docs/code-signing-policy.md requires all three project-owned PE
# layers (main exe, uninstaller, installer) to verify before
# publication. The signed main exe and the uninstaller only exist as
# files once the installer has been installed (NSIS writes the
# uninstaller during install; the on-disk build-output main exe is
# restored to unsigned by tauri-bundler), so this reuses the packaged
# lifecycle smoke script win-installer-check.yml already proves on
# PRs (throwaway-cert builds there) — here against the just-built
# -setup.exe, before this job publishes anything. It runs whenever a
# cloud provider is selected (mode optional/required follows
# AUTHENTICODE_REQUIRED), so the recommended "run 1-2 releases in
# optional mode first" rollout still hard-checks that every layer
# carries the provider's certificate, while a default unsigned
# release (no provider, enforcement off) skips this extra
# install/launch/upgrade/uninstall pass entirely, exactly as today.
# A failure here blocks the release. windows-latest is x64, so a
# cross-built ARM64 installer cannot install/run here — see
# docs/windows-signing.md's ARM64 runtime-verification checklist
# (item 6) for the manual check that covers that architecture.
- name: Installed main exe + uninstaller Authenticode gate (x64)
if: matrix.os == 'windows' && matrix.target == 'x86_64-pc-windows-msvc' && (vars.AUTHENTICODE_REQUIRED == 'true' || vars.WINDOWS_SIGNING_PROVIDER == 'esigner' || vars.WINDOWS_SIGNING_PROVIDER == 'certum')
shell: pwsh
env:
AUTHENTICODE_MODE: ${{ vars.AUTHENTICODE_REQUIRED == 'true' && 'required' || 'optional' }}
run: |
$setup = Get-ChildItem "target/${{ matrix.target }}/release/bundle/nsis/*-setup.exe" -ErrorAction Stop
& .\scripts\windows-packaged-smoke.ps1 `
-Installer $setup.FullName `
-AuthenticodeMode $env:AUTHENTICODE_MODE `
-ExpectedThumbprint $env:WINDOWS_SIGNING_THUMBPRINT
# ── Windows: sign the NSIS installer for the updater ──────────────────
# Tauri never emits an updater `.sig` on its own during build; this is
# unrelated to whether Authenticode signing is configured. In Tauri 2
# the Windows updater ships the `-setup.exe` itself, so signing it here
# with the updater key — exactly what finalize-macos.sh does for the
# .app.tar.gz — is equivalent to `createUpdaterArtifacts` and is what
# gets windows-x86_64 and windows-aarch64 into latest.json. Without it
# the manifest has macOS platforms only and Windows in-app self-update
# never sees new versions.
# NOTE: updater signature ≠ Authenticode ≠ SmartScreen reputation.
- name: Sign Windows updater artifact
if: matrix.os == 'windows'
shell: bash
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
echo "::group::[sign] Tauri updater signature (.sig)"
B="target/${{ matrix.target }}/release/bundle"
shopt -s nullglob
signed=0
for exe in "$B"/nsis/*-setup.exe; do
npx tauri signer sign "$exe"
signed=$((signed + 1))
done
if [ "$signed" -eq 0 ]; then
echo "::error::[sign] no NSIS -setup.exe found to sign under $B/nsis" >&2
exit 1
fi
echo "::endgroup::"
# ── Collect artifacts (rename macOS updater tarball with its arch so the
# two macOS jobs don't collide when merged) ──────────────────────────
- name: Collect artifacts
shell: bash
run: |
echo "::group::[build] collect final publishable artifacts"
DST="$GITHUB_WORKSPACE/dist-artifacts"; mkdir -p "$DST"
B="target/${{ matrix.target }}/release/bundle"
ARCH="${{ matrix.target }}"; ARCH="${ARCH%%-*}"
shopt -s nullglob
if [ "${{ matrix.os }}" = "macos" ]; then
for f in "$B"/macos/*.app.tar.gz; do cp "$f" "$DST/CodexAppManager_${ARCH}.app.tar.gz"; done
for f in "$B"/macos/*.app.tar.gz.sig; do cp "$f" "$DST/CodexAppManager_${ARCH}.app.tar.gz.sig"; done
for f in "$B"/dmg/*.dmg; do cp "$f" "$DST/CodexAppManager_${ARCH}.dmg"; done
else
# Strip spaces from the NSIS filenames (productName is "Codex App
# Manager"). GitHub rewrites spaces to dots in uploaded asset names,
# but gen-updater-manifest.mjs builds the URL from the pre-upload
# filename — so a spaced name yields a %20 URL that 404s against the
# dotted asset. Renaming space-free here keeps asset name == manifest
# URL (the macOS branch already renames for the same reason).
for f in "$B"/nsis/*-setup.exe "$B"/nsis/*-setup.exe.sig \
"$B"/nsis/*.nsis.zip "$B"/nsis/*.nsis.zip.sig; do
n="$(basename "$f")"; cp "$f" "$DST/${n// /}"
done
fi
ls -la "$DST"
# Gate on final publishable names (not intermediate target/ paths).
if [ "${{ matrix.os }}" = "windows" ]; then
setup_count=$(ls -1 "$DST"/*-setup.exe 2>/dev/null | wc -l | tr -d ' ')
sig_count=$(ls -1 "$DST"/*-setup.exe.sig 2>/dev/null | wc -l | tr -d ' ')
if [ "$setup_count" -lt 1 ]; then
echo "::error::[build] no final *-setup.exe in dist-artifacts" >&2
exit 1
fi
if [ "$sig_count" -lt 1 ]; then
echo "::error::[sign] no final *-setup.exe.sig in dist-artifacts (updater signature missing)" >&2
exit 1
fi
else
dmg_count=$(ls -1 "$DST"/*.dmg 2>/dev/null | wc -l | tr -d ' ')
if [ "$dmg_count" -lt 1 ]; then
echo "::error::[build] no final *.dmg in dist-artifacts" >&2
exit 1
fi
fi
echo "::endgroup::"
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: bundle-${{ matrix.target }}-${{ github.run_id }}-${{ github.run_attempt }}
path: dist-artifacts/*
if-no-files-found: error
# Signing and notarization are intentionally non-deterministic. Keep every
# successful matrix artifact under an attempt-qualified name, then select the
# earliest successful artifact for each target. A release/stage rerun therefore
# reuses the exact first bytes instead of creating a second immutable payload.
select_artifacts:
needs: [prepare, build]
if: ${{ always() && needs.prepare.result == 'success' }}
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
outputs:
artifact_names: ${{ steps.select.outputs.artifact_names }}
steps:
- name: Select canonical build artifacts
id: select
if: ${{ needs.prepare.outputs.release_reusable != 'true' }}
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
targets=(
aarch64-apple-darwin
x86_64-apple-darwin
x86_64-pc-windows-msvc
aarch64-pc-windows-msvc
)
names_file="$RUNNER_TEMP/canonical-artifact-names.txt"
: > "$names_file"
for poll in 1 2 3 4 5; do
gh api --paginate \
"repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/artifacts?per_page=100" \
--jq '.artifacts[].name' > "$RUNNER_TEMP/run-artifacts.txt"
: > "$names_file"
complete=true
for target in "${targets[@]}"; do
selected=""
for attempt in $(seq 1 "$GITHUB_RUN_ATTEMPT"); do
candidate="bundle-${target}-${GITHUB_RUN_ID}-${attempt}"
if grep -Fxq "$candidate" "$RUNNER_TEMP/run-artifacts.txt"; then
selected="$candidate"
break
fi
done
if [[ -z "$selected" ]]; then
complete=false
break
fi
echo "$selected" >> "$names_file"
done
[[ "$complete" == "true" ]] && break
sleep 5
done
[[ "$complete" == "true" ]] || {
echo "::error::no complete canonical release artifact set exists for this run"
exit 1
}
artifact_names="$(jq -Rsc 'split("\n") | map(select(length > 0))' "$names_file")"
echo "artifact_names=$artifact_names" >> "$GITHUB_OUTPUT"
echo "Canonical artifacts: $artifact_names"
release:
if: ${{ always() && needs.preflight.result == 'success' && needs.prepare.result == 'success' && needs.select_artifacts.result == 'success' }}
needs: [preflight, prepare, build, select_artifacts]
# All mirror credentials live only in the default-branch-restricted release
# environment. Never duplicate them as repository or organization secrets.
environment: release
env:
TRUSTED_WORKFLOW_SHA: ${{ github.workflow_sha }}
TRUSTED_WORKFLOW_SOURCE_SHA: ${{ github.sha }}
permissions:
contents: write
actions: write # dispatch winget.yml after publishing (release events from GITHUB_TOKEN don't cascade)
id-token: write # provenance attestations
attestations: write
runs-on: ubuntu-latest
steps:
- name: Authorize credentialed job rerun actor
shell: bash
env:
CURRENT_TRIGGERING_ACTOR_LOGIN: ${{ github.triggering_actor }}
run: |
set -euo pipefail
[[ "$CURRENT_TRIGGERING_ACTOR_LOGIN" == "$AUTHORIZED_RELEASE_ACTOR_LOGIN" ]] || {
echo "::error::credentialed release job rerun actor is not the authorized publisher"
exit 1
}
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# Historical workflow revisions used the legacy credential names and wrote
# latest.json unconditionally. Keeping those secrets deleted is the storage
# authorization boundary that makes an old run's "Re-run all jobs" fail
# before it can mutate either mirror.
- name: Enforce legacy mirror credential revocation
shell: bash
env:
LEGACY_R2_ACCESS_KEY_ID: ${{ secrets.MANAGER_R2_ACCESS_KEY_ID }}
LEGACY_R2_SECRET_ACCESS_KEY: ${{ secrets.MANAGER_R2_SECRET_ACCESS_KEY }}
LEGACY_IHEP_ACCESS_KEY_ID: ${{ secrets.MANAGER_IHEP_S3_ACCESS_KEY_ID }}
LEGACY_IHEP_SECRET_ACCESS_KEY: ${{ secrets.MANAGER_IHEP_S3_SECRET_ACCESS_KEY }}
run: |
if [[ -n "$LEGACY_R2_ACCESS_KEY_ID$LEGACY_R2_SECRET_ACCESS_KEY$LEGACY_IHEP_ACCESS_KEY_ID$LEGACY_IHEP_SECRET_ACCESS_KEY" ]]; then
echo "::error::legacy mirror write secrets are still configured; move credentials to the *_PROMOTION_* names and delete all four legacy access-key secrets"
exit 1
fi
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24
# Re-resolve tag policy on every release-job attempt. For manual recovery
# this independently resolves the immutable tag; a signal run must still
# match the SHA carried by the original successful push workflow.
- name: Re-resolve protected immutable release tag
id: tag_source
env:
ALLOW_RELEASE_SHA_RESOLUTION: ${{ needs.preflight.outputs.trusted_invocation == 'manual-recovery' && '1' || '0' }}
GH_TOKEN: ${{ secrets.IMMUTABLE_RELEASES_READ_TOKEN }}
RELEASE_SOURCE_SHA: ${{ needs.preflight.outputs.release_source_sha }}
run: node scripts/check-release-tag-protection.mjs "$RELEASE_TAG" "$RELEASE_SOURCE_SHA"
# Re-run this gate inside every release-job attempt. GitHub's "Re-run
# failed jobs" does not re-run prepare, and no immutable publication may
# rely only on a previous attempt's source or ancestry decision.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ steps.tag_source.outputs.release_source_sha }}
path: release-source
persist-credentials: false
fetch-depth: 0
- name: Re-check release tag against application versions
shell: bash
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
EXPECTED_RELEASE_SOURCE_SHA: ${{ steps.tag_source.outputs.release_source_sha }}
run: |
set -euo pipefail
node scripts/check-release-version.mjs source "$RELEASE_TAG" release-source
release_source_sha="$(git -C release-source rev-parse HEAD)"
if [[ "$release_source_sha" != "$EXPECTED_RELEASE_SOURCE_SHA" ]]; then
echo "::error::release checkout differs from verified commit $EXPECTED_RELEASE_SOURCE_SHA"
exit 1
fi