Repository navigation
1154 lines (1084 loc) · 58.4 KB
/
Copy pathrelease.yml
File metadata and controls
1154 lines (1084 loc) · 58.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
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
on:
push:
tags:
- '[0-9]*'
workflow_dispatch:
inputs:
channel:
description: Update channel for this release
required: true
default: stable
type: choice
options:
- stable
- beta
# Nightly releases are explicitly rejected (#503): beta is the only
# prerelease distribution channel.
release_tag:
description: Existing release tag to build or promote
required: true
type: string
promote_stable:
description: Explicitly select this verified stable release as GitHub Latest
required: true
default: false
type: boolean
candidate_manifest_asset_id:
description: GitHub asset ID of the immutable candidate-manifest.json used for promotion
required: false
type: string
candidate_manifest_sha256:
description: SHA-256 of the immutable candidate-manifest.json used for promotion
required: false
type: string
permissions:
contents: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
# false, not true (#178): a release tag doesn't move, so a second trigger on
# the same ref is a re-run of the same release, not new work superseding it.
# Cancelling mid-flight can leave a release with some platforms' assets
# uploaded and others missing/corrupt, with no clear signal that happened.
cancel-in-progress: false
jobs:
create-release:
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
version: ${{ steps.release-metadata.outputs.version }}
channel: ${{ steps.release-metadata.outputs.channel }}
manifest_prefix: ${{ steps.release-metadata.outputs.manifest_prefix }}
prerelease: ${{ steps.release-metadata.outputs.prerelease }}
make_latest: ${{ steps.release-metadata.outputs.make_latest }}
promotion_only: ${{ steps.release-metadata.outputs.promotion_only }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
fetch-depth: 0 # full tag history — needed for the "Full Changelog" compare link below
- name: Determine release metadata
id: release-metadata
run: |
VERSION=$(node -p "require('./package.json').version")
if [ "$RELEASE_EVENT_NAME" = "push" ]; then
TAG="$RELEASE_REF_NAME"
else
TAG="$RELEASE_TAG_INPUT"
if [ "$RELEASE_REF_NAME" != "$TAG" ]; then
echo "::error::Manual release dispatch must run from the selected release tag '$TAG', not '$RELEASE_REF_NAME'."
exit 1
fi
fi
VERSION_CHANNEL="stable"
if [[ "$VERSION" == *-* ]]; then
VERSION_CHANNEL="${VERSION#*-}"
VERSION_CHANNEL="${VERSION_CHANNEL%%.*}"
fi
REQUESTED_CHANNEL="${{ inputs.channel }}"
CHANNEL="${REQUESTED_CHANNEL:-$VERSION_CHANNEL}"
case "$CHANNEL" in
stable|beta) ;;
*) echo "::error::Unsupported release channel '$CHANNEL' (expected stable or beta; nightlies are rejected by #503)."; exit 1 ;;
esac
if [ "$CHANNEL" = "stable" ] && [ "$VERSION_CHANNEL" != "stable" ]; then
echo "::error::A stable channel release must use a stable package version, got '$VERSION'."
exit 1
fi
if [ "$CHANNEL" != "stable" ] && [ "$VERSION_CHANNEL" != "$CHANNEL" ]; then
echo "::error::A $CHANNEL channel release must use a $CHANNEL prerelease version, got '$VERSION'."
exit 1
fi
PROMOTE="${{ inputs.promote_stable }}"
PROMOTE="${PROMOTE:-false}"
if [ "$PROMOTE" = "true" ] && [ "$CHANNEL" != "stable" ]; then
echo "::error::Only a stable release can be explicitly selected as GitHub Latest."
exit 1
fi
PROMOTION_ONLY=false
if [ "$RELEASE_EVENT_NAME" = "workflow_dispatch" ] && [ "$PROMOTE" = "true" ]; then
PROMOTION_ONLY=true
fi
PRERELEASE=false
[ "$CHANNEL" != "stable" ] && PRERELEASE=true
MANIFEST_PREFIX=latest
[ "$CHANNEL" != "stable" ] && MANIFEST_PREFIX="$CHANNEL"
{
echo "version=$VERSION"
echo "tag=$TAG"
echo "channel=$CHANNEL"
echo "manifest_prefix=$MANIFEST_PREFIX"
echo "prerelease=$PRERELEASE"
echo "make_latest=$PROMOTE"
echo "promotion_only=$PROMOTION_ONLY"
} >> "$GITHUB_OUTPUT"
env:
RELEASE_EVENT_NAME: ${{ github.event_name }}
RELEASE_REF_NAME: ${{ github.ref_name }}
RELEASE_TAG_INPUT: ${{ inputs.release_tag }}
- name: Validate release tag
# `on.push.tags: '[0-9]*'` is a glob, not a version pattern — it also
# matches prerelease tags. Validate the complete semver and package
# version before creating the draft release (issue #220).
run: |
TAG="$RELEASE_TAG"
VERSION="$RELEASE_VERSION"
if ! [[ "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]]; then
echo "::error::Tag '${TAG}' is not a valid release version (expected X.Y.Z or an X.Y.Z-beta.N prerelease)."
exit 1
fi
if [ "$TAG" != "$VERSION" ]; then
echo "::error::Tag '${TAG}' does not match package.json version '${VERSION}'."
exit 1
fi
if [ "$RELEASE_EVENT_NAME" != "push" ]; then
TAG_COMMIT=$(git rev-parse "${TAG}^{commit}") || {
echo "::error::Release tag '${TAG}' does not exist in the checked-out repository."
exit 1
}
HEAD_COMMIT=$(git rev-parse HEAD)
if [ "$TAG_COMMIT" != "$HEAD_COMMIT" ]; then
echo "::error::Checked-out revision '$HEAD_COMMIT' does not match release tag '${TAG}' at '$TAG_COMMIT'."
exit 1
fi
fi
env:
RELEASE_TAG: ${{ steps.release-metadata.outputs.tag }}
RELEASE_VERSION: ${{ steps.release-metadata.outputs.version }}
RELEASE_EVENT_NAME: ${{ github.event_name }}
- name: Validate release provenance
if: steps.release-metadata.outputs.promotion_only != 'true'
run: |
node scripts/release-gate/validate-release-ref.cjs \
--repo "${{ github.repository }}" \
--tag "$RELEASE_TAG" \
--commit "${{ github.sha }}" \
--main-ref main
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ steps.release-metadata.outputs.tag }}
- name: Create GitHub draft release (if not exists)
# Release notes are extracted from an existing draft release body if present,
# or generated via git-cliff (falling back to changelog-notes.sh / CHANGELOG.md),
# not --generate-notes: this repo pushes straight to main instead of merging PRs,
# so GitHub's PR-based auto-generated notes come back empty (see git history around
# v2.0.0/v2.0.3 for what that looked like). Valid non-empty release notes are
# mandatory for every release — this step fails loudly instead of publishing a
# release with empty notes. The release is deliberately kept as a draft until all
# platform assets pass the post-upload verification job.
if: steps.release-metadata.outputs.promotion_only != 'true'
run: |
TAG="$RELEASE_TAG"
VERSION="$RELEASE_VERSION"
CHANNEL="$RELEASE_CHANNEL"
BASE_VERSION="${VERSION%%-*}"
if gh release view "${TAG}" --repo "${{ github.repository }}" >/dev/null 2>&1; then
DRAFT=$(gh release view "${TAG}" --repo "${{ github.repository }}" --json isDraft --jq .isDraft)
if [ "$DRAFT" != "true" ]; then
echo "::error::Release ${TAG} already exists and is published; refusing to overwrite it."
exit 1
fi
echo "Draft release ${TAG} already exists, extracting existing release notes."
BODY=$(gh release view "${TAG}" --repo "${{ github.repository }}" --json body --jq .body)
if [ -z "$(echo "${BODY}" | tr -d '[:space:]')" ]; then
echo "::error::Existing draft release ${TAG} has an empty body; refusing to package release with empty notes."
exit 1
fi
printf '%s\n' "${BODY}" > /tmp/release-notes.md
# Normalize the title on every rerun, in case the draft was created before
# the title format last changed (e.g. still carries a stale "(channel)" suffix).
gh release edit "${TAG}" --repo "${{ github.repository }}" --title "${VERSION}"
else
# Generate release notes using git-cliff, falling back to changelog-notes.sh
if npx --yes [email protected] --latest --strip header > /tmp/release-notes.md 2>/dev/null && [ -s /tmp/release-notes.md ]; then
echo "Generated release notes for ${TAG} via git-cliff"
elif ./scripts/changelog-notes.sh "${BASE_VERSION}" > /tmp/release-notes.md 2>/dev/null && [ -s /tmp/release-notes.md ]; then
echo "Extracted release notes for ${BASE_VERSION} from CHANGELOG.md"
else
echo "::error::Could not generate release notes via git-cliff or CHANGELOG.md for tag ${TAG}."
exit 1
fi
# Determine previous tag using semver (strictly less than current version)
PREV_TAG=$(node -e '
const { execSync } = require("child_process");
function parse(v) {
const m = (v || "").trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/);
if (!m) return null;
return { major: +m[1], minor: +m[2], patch: +m[3], pre: m[4] ? m[4].split(".") : null, tag: v.trim() };
}
function comparePre(a, b) {
if (!a && !b) return 0;
if (!a && b) return 1;
if (a && !b) return -1;
for (let i = 0; i < Math.max(a.length, b.length); i++) {
if (i >= a.length) return -1;
if (i >= b.length) return 1;
if (a[i] === b[i]) continue;
const aNum = /^\d+$/.test(a[i]), bNum = /^\d+$/.test(b[i]);
if (aNum && bNum) return +a[i] - +b[i];
if (aNum && !bNum) return -1;
if (!aNum && bNum) return 1;
return a[i] < b[i] ? -1 : 1;
}
return 0;
}
function compare(a, b) {
if (a.major !== b.major) return a.major - b.major;
if (a.minor !== b.minor) return a.minor - b.minor;
if (a.patch !== b.patch) return a.patch - b.patch;
return comparePre(a.pre, b.pre);
}
const current = parse(process.argv[1]);
if (!current) process.exit(0);
const tags = execSync("git tag --list", { encoding: "utf8" }).split("\n").filter(Boolean);
const prev = tags
.filter(t => !t.startsWith("tax-pack-"))
.map(parse)
.filter(Boolean)
.filter(t => compare(t, current) < 0)
.sort((a, b) => compare(b, a))[0];
if (prev) console.log(prev.tag);
' "$VERSION" 2>/dev/null || true)
if [ -n "$PREV_TAG" ]; then
echo "" >> /tmp/release-notes.md
echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/${PREV_TAG}...${VERSION}" >> /tmp/release-notes.md
fi
RELEASE_ARGS=(--draft --latest=false)
[ "$CHANNEL" != "stable" ] && RELEASE_ARGS+=(--prerelease)
gh release create "${TAG}" \
--repo "${{ github.repository }}" \
--verify-tag \
--title "${VERSION}" \
--notes-file /tmp/release-notes.md \
"${RELEASE_ARGS[@]}"
fi
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ steps.release-metadata.outputs.tag }}
RELEASE_VERSION: ${{ steps.release-metadata.outputs.version }}
RELEASE_CHANNEL: ${{ steps.release-metadata.outputs.channel }}
- name: Upload release notes artifact
if: steps.release-metadata.outputs.promotion_only != 'true'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: release-notes
path: /tmp/release-notes.md
retention-days: 30
overwrite: true
- name: Upload standalone uninstaller scripts
# Independent utility scripts, not part of the app bundle — for
# users who need to fully remove/reinstall outside the packaged
# per-platform uninstaller (e.g. a broken install).
if: steps.release-metadata.outputs.promotion_only != 'true'
run: |
node scripts/release-gate/ensure-release-assets.cjs \
--repo "${{ github.repository }}" \
--tag "$RELEASE_TAG" \
--file scripts/uninstallers/uninstall-macos.sh \
--file scripts/uninstallers/uninstall-windows.ps1
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ steps.release-metadata.outputs.tag }}
release-linux:
runs-on: ${{ matrix.runner }}
needs: create-release
if: needs.create-release.outputs.promotion_only != 'true'
timeout-minutes: 60
# Each matrix entry builds AppImage + deb + rpm + snap for its host
# architecture. electron-builder does not cross-compile, so x64 hosts
# produce x86_64 / amd64 artifacts and arm64 hosts produce arm64 ones.
# The release command pins safe x64/arm64 matrix labels into every Linux
# filename, so both upload to the same GitHub release without collisions.
# Both publish their snap to Snap Store under one snap name with multi-arch
# revisions.
strategy:
fail-fast: false
matrix:
include:
- arch: x64
runner: ubuntu-24.04
- arch: arm64
runner: ubuntu-24.04-arm
concurrency:
# Scoped per-arch so x64 and arm64 (distinct group keys) never contend with
# each other — each only queues behind a previous run of that *same* arch on
# this ref. cancel-in-progress is false for the same reason as the
# top-level group above (#178): don't kill a mid-upload job on re-trigger.
group: release-linux-${{ github.ref }}-${{ matrix.arch }}
cancel-in-progress: false
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '22'
- name: Download release notes artifact
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release-notes
path: /tmp
- name: Install system dependencies
# rpm packaging tools are x86-only via apt — arm64 runner ships its
# own rpm toolchain.
if: matrix.arch == 'x64'
run: sudo apt-get update && sudo apt-get install -y build-essential libssl-dev rpm
- name: Set up LXD
# LXD is the snap build backend (snapcraft.core24.useLXD). The official
# canonical/setup-lxd action provisions it with the lxdbr0 bridge + NAT,
# which the manual `lxd init --minimal` route failed to do (containers had
# no network). Pattern mirrors beekeeper-studio#4427, which fixed the same
# broken core24 snap builds in production. Pinned to a commit SHA (issue
# #220) rather than @main, which is a mutable ref in a workflow with
# write permissions.
uses: canonical/setup-lxd@8c6a87bfb56aa48f3fb9b830baa18562d8bfd4ee # v1
- name: Install snapcraft
# Required for both matrix entries: x64 and arm64 each produce a snap.
run: sudo snap install snapcraft --classic
- name: Install npm dependencies
run: npm ci
env:
npm_config_build_from_source: true
- name: Prepend AppStream release entry
# Idempotent. x64 entry modifies the file; arm64 entry sees the
# already-fresh entry and skips. Safe to run on both.
run: node scripts/update-metainfo.js
- name: Prepare Snapcraft desktop entry
run: node scripts/prepare-snap-build.cjs
- name: Build Linux artifacts
# Builds AppImage + deb + rpm + snap for the host arch. The release
# overrides below use the safe x64/arm64 matrix label rather than
# electron-builder's target-specific x86_64/amd64/aarch64 spellings;
# the snap is built via `snapcraft pack --use-lxd` in the LXD container,
# with the gnome extension and default stage-packages (libnspr4, libnss3, libxss1,
# libappindicator3-1, libsecret-1-0). SNAPCRAFT_BUILD_ENVIRONMENT=lxd
# selects the LXD provider explicitly.
run: |
npm run build:frontend && npm run build && npx electron-builder --linux AppImage deb rpm snap --publish never \
-c.publish.channel="${{ needs.create-release.outputs.manifest_prefix }}" \
-c.linux.artifactName="flocafe-\${version}-linux-\${env.FLO_LINUX_ARCH}.\${ext}" \
-c.appImage.artifactName="flocafe-\${version}-linux-\${env.FLO_LINUX_ARCH}.appimage"
node scripts/assert-release-artifact-names.cjs release
env:
SNAPCRAFT_BUILD_ENVIRONMENT: lxd
FLO_LINUX_ARCH: ${{ matrix.arch }}
GOOGLE_DRIVE_CLIENT_ID: ${{ secrets.GOOGLE_DRIVE_CLIENT_ID }}
GOOGLE_DRIVE_CLIENT_SECRET: ${{ secrets.GOOGLE_DRIVE_CLIENT_SECRET }}
- name: Verify Linux release assets
run: |
MANIFEST_PREFIX="${{ needs.create-release.outputs.manifest_prefix }}"
MANIFEST_NAME="${MANIFEST_PREFIX}-linux.yml"
if [ "${{ matrix.arch }}" = "arm64" ]; then MANIFEST_NAME="${MANIFEST_PREFIX}-linux-arm64.yml"; fi
test -f "release/${MANIFEST_NAME}" || { echo "::error::release/${MANIFEST_NAME} is missing — Linux auto-update would be broken for this release"; exit 1; }
find release -maxdepth 1 -type f -name "*linux-${{ matrix.arch }}.appimage" -print -quit | grep -q . || {
echo "::error::no Linux AppImage for ${{ matrix.arch }} — Linux auto-update would be broken for this release"
exit 1
}
- name: Upload Linux assets to GitHub release
# Each matrix entry uploads its own arch-tagged artifacts. The release
# override uses the safe x64/arm64 matrix labels, so entries never
# collide on the same GitHub release and no post-build rename is needed.
run: |
VERSION=$(node -p "require('./package.json').version")
mapfile -t FILES < <(find release -maxdepth 1 -type f \( -name '*.appimage' -o -name '*.deb' -o -name '*.rpm' -o -name '*.snap' \) -print)
MANIFEST_PREFIX="${{ needs.create-release.outputs.manifest_prefix }}"
MANIFEST_NAME="${MANIFEST_PREFIX}-linux.yml"
if [ "${{ matrix.arch }}" = "arm64" ]; then MANIFEST_NAME="${MANIFEST_PREFIX}-linux-arm64.yml"; fi
[ -f "release/${MANIFEST_NAME}" ] || { echo "::error::release/${MANIFEST_NAME} is missing for ${{ matrix.arch }}"; exit 1; }
FILES+=("release/${MANIFEST_NAME}")
[ ${#FILES[@]} -gt 0 ] || { echo "::error::no Linux artifacts produced for ${{ matrix.arch }}"; exit 1; }
UPLOAD_ARGS=(--repo "${{ github.repository }}" --tag "$VERSION")
for FILE in "${FILES[@]}"; do UPLOAD_ARGS+=(--file "$FILE"); done
node scripts/release-gate/ensure-release-assets.cjs "${UPLOAD_ARGS[@]}"
env:
GH_TOKEN: ${{ github.token }}
- name: Publish snap to the matching Snap Store channel
# Both matrix entries publish their snap, each tagged with its arch.
# Snap Store keeps multiple per-arch revisions under one snap name.
# Release channel → store channel: stable→stable, beta→edge (#468);
# this keeps a prerelease out of the stable package-manager channel.
# The credentials check is at runtime, not in `if:` — GitHub Actions
# rejects `secrets` in `if:` expressions (unrecognized named-value).
# A missing credential is a release failure, not a skipped channel:
# if this job runs, Snap publishing was requested.
env:
GH_TOKEN: ${{ github.token }}
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
run: |
if [ -z "$SNAPCRAFT_STORE_CREDENTIALS" ]; then
echo "::error::SNAPCRAFT_STORE_CREDENTIALS not set — refusing to silently skip requested Snap Store publish for ${{ matrix.arch }}"
exit 1
fi
SNAP_NAME="flocafe"
SNAP=$(find release -maxdepth 1 -type f -name '*.snap' -print -quit)
[ -n "$SNAP" ] || { echo "::error::No .snap produced by electron-builder for ${{ matrix.arch }}"; exit 1; }
# #468: the SNAPCRAFT_STORE_CREDENTIALS macaroon is restricted to
# the stable and edge channels, so a beta release publishes to
# `edge` (opt-in channel) instead of the beta channel; the evidence
# marker records the real store channel.
case "${{ needs.create-release.outputs.channel }}" in
stable) SNAP_CHANNEL="stable" ;;
beta) SNAP_CHANNEL="edge" ;;
*) echo "::error::Unexpected release channel '${{ needs.create-release.outputs.channel }}'"; exit 1 ;;
esac
snapcraft register "$SNAP_NAME" || echo "::warning::register skipped/failed; continuing"
# Degrade exactly a credential-scope denial to a loud warning (#468):
# the GitHub release still gets the full .snap artifact set; failing
# the whole release build for a Store-side credential scope would
# block every other platform's updater feed.
if ! UPLOAD_OUT=$(snapcraft upload "$SNAP" --release="$SNAP_CHANNEL" 2>&1); then
printf '%s\n' "$UPLOAD_OUT"
if printf '%s' "$UPLOAD_OUT" | grep -q "invalid-channel-permission"; then
echo "::warning::Snap Store credentials do not permit the '$SNAP_CHANNEL' channel (macaroon restriction). The .snap remains attached to this GitHub release; re-export SNAPCRAFT_STORE_CREDENTIALS with that channel enabled to restore Store publishing (#468)."
exit 0
fi
echo "::error::snapcraft upload failed"
exit 1
fi
printf '%s\n' "$UPLOAD_OUT"
# A sanitized, per-architecture marker is uploaded to the draft only
# after Snap Store publication succeeds. Stable promotion checks these
# markers, so a missing/failed stable Snap can never be mistaken for
# a published stable release. No snapcraft output or credentials are
# retained in the evidence.
SNAP_EVIDENCE="$RUNNER_TEMP/snap-publication-${{ matrix.arch }}.json"
node scripts/release-gate/snap-evidence.cjs \
--tag "$(node -p "require('./package.json').version")" \
--channel "$SNAP_CHANNEL" \
--arch "${{ matrix.arch }}" \
--output "$SNAP_EVIDENCE"
node scripts/release-gate/ensure-release-assets.cjs \
--repo "${{ github.repository }}" \
--tag "$(node -p "require('./package.json').version")" \
--file "$SNAP_EVIDENCE"
release-mac:
runs-on: macos-14
needs: create-release
if: needs.create-release.outputs.promotion_only != 'true'
# Was two parallel 60-minute matrix jobs (one per arch); now one job builds,
# signs, and notarizes both — packaging + notarizing arch #2 no longer
# overlaps arch #1's wall-clock time, so this needs real headroom over the
# old single-arch budget.
timeout-minutes: 100
concurrency:
group: release-mac-${{ github.ref }}
cancel-in-progress: false
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '22'
- name: Install npm dependencies
run: npm ci
- name: Verify Electron dev runtime
# npm ci already runs this via package.json's postinstall — repeated
# here as an explicit, visible gate before the build below relies on it.
run: npm run verify:electron
- name: Verify macOS signing credentials
# Fail loudly rather than let electron-builder silently emit an
# unsigned/unnotarized build — that shipped for every release before
# this check existed and is exactly what gets the app Gatekeeper-blocked
# ("...should move it to the Bin") for anyone who downloads it.
#
# Notarization uses an App Store Connect API key rather than
# Apple ID + app-specific password (#168): the password-based flow
# works fine run interactively from a trusted Mac but was reliably
# rejected by Apple with a misleading "Your Apple ID has been locked"
# 401 when run from GitHub Actions — a known limitation of
# password-based auth from automated/CI contexts. API keys don't
# expire and sidestep this entirely; see electron.build/docs/notarization.
run: |
missing=()
[ -z "$CSC_LINK" ] && missing+=("MAC_CERTS")
[ -z "$CSC_KEY_PASSWORD" ] && missing+=("MAC_CERTS_PASSWORD")
[ -z "$APPLE_API_KEY" ] && missing+=("APPLE_API_KEY")
[ -z "$APPLE_API_KEY_ID" ] && missing+=("APPLE_API_KEY_ID")
[ -z "$APPLE_API_ISSUER" ] && missing+=("APPLE_API_ISSUER")
if [ ${#missing[@]} -gt 0 ]; then
echo "::error::Missing required secret(s) for signed/notarized macOS build: ${missing[*]}"
exit 1
fi
env:
CSC_LINK: ${{ secrets.MAC_CERTS }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
- name: Write App Store Connect API key to disk
# @electron/notarize's notarytool strategy expects APPLE_API_KEY to be
# a *file path* to the .p8, not its contents — unlike CSC_LINK, which
# electron-builder accepts as base64. The secret holds the base64 of
# the .p8 (see CONTRIBUTING.md); decode it to a temp file per run.
run: |
KEY_PATH="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8"
echo "$APPLE_API_KEY" | base64 --decode > "$KEY_PATH"
echo "APPLE_API_KEY_PATH=$KEY_PATH" >> "$GITHUB_ENV"
env:
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
- name: Set up macOS signing keychain
# Create an ephemeral keychain explicitly so set-key-partition-list receives
# the keychain password (not the cert password). electron-builder then consumes
# CSC_KEYCHAIN directly without attempting internal keychain recreation.
run: |
KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain"
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
echo "$CSC_LINK" | base64 --decode > "$RUNNER_TEMP/certificate.p12"
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security import "$RUNNER_TEMP/certificate.p12" -k "$KEYCHAIN_PATH" -P "$CSC_KEY_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security list-keychains -d user -s "$KEYCHAIN_PATH" $(security list-keychains -d user | tr -d '"')
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
echo "CSC_KEYCHAIN=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
env:
CSC_LINK: ${{ secrets.MAC_CERTS }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
- name: Build macOS
# Both archs in one electron-builder invocation (#199, #178): package.json's
# build.mac.target already declares arch: [x64, arm64] for dmg/zip, and
# npmRebuild defaults to true, so electron-builder rebuilds native deps
# (better-sqlite3) per arch on its own — the same machinery the existing
# `build:mas --universal` script already relies on. This is what actually
# fixes the latest-mac.yml race: electron-builder's own updateInfoBuilder
# only omits an arch suffix from the mac update-manifest filename (unlike
# Linux), and merges same-named update-info tasks by concatenating their
# `files` entries — so one process building both archs produces a single
# correctly-merged latest-mac.yml instead of two per-arch ones stepping on
# each other via per-architecture asset names.
run: |
npm run build:frontend && npm run build && npx electron-builder --mac --x64 --arm64 --publish never \
-c.publish.channel="${{ needs.create-release.outputs.manifest_prefix }}"
node scripts/assert-release-artifact-names.cjs release
env:
CSC_KEYCHAIN: ${{ env.CSC_KEYCHAIN }}
APPLE_API_KEY: ${{ env.APPLE_API_KEY_PATH }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_TEAM_ID: BKDY677XJA
GOOGLE_DRIVE_CLIENT_ID: ${{ secrets.GOOGLE_DRIVE_CLIENT_ID }}
GOOGLE_DRIVE_CLIENT_SECRET: ${{ secrets.GOOGLE_DRIVE_CLIENT_SECRET }}
- name: Verify macOS release assets
run: |
export PATH="/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
MANIFEST_PREFIX="${{ needs.create-release.outputs.manifest_prefix }}"
test -f "release/${MANIFEST_PREFIX}-mac.yml" || { echo "::error::release/${MANIFEST_PREFIX}-mac.yml is missing — macOS auto-update would be broken for this release"; exit 1; }
ZIP_FILES=(release/*.zip)
ZIP_BLOCKMAP_FILES=(release/*.zip.blockmap)
[ -f "${ZIP_FILES[0]}" ] || { echo "::error::no .zip in release/ — macOS auto-update would be broken for this release"; exit 1; }
[ -f "${ZIP_BLOCKMAP_FILES[0]}" ] || { echo "::error::no .zip.blockmap in release/ — macOS auto-update would be broken for this release"; exit 1; }
# Guards against a regression back to #199/#178: this should list one
# `files:` entry per .zip built (both archs), not just exist.
#
# mac.target configures both dmg and zip (each x64+arm64), and
# electron-builder's update-info writer lists both under `files:` in
# latest-mac.yml, not just zip — confirmed directly against a local
# v26.15.3 build (v2.5.0's release-mac run failed here with a false
# positive: 4 total `- url:` lines against 2 zips, because dmg
# entries were being counted as if they were missing zip entries).
# electron-updater's MacUpdater only ever selects a `.zip` entry, so
# only those need to be counted here.
ZIP_COUNT=${#ZIP_FILES[@]}
MANIFEST_COUNT=$(grep -cE '^\s*- url: .*\.zip$' "release/${MANIFEST_PREFIX}-mac.yml" || true)
if [ "$MANIFEST_COUNT" -ne "$ZIP_COUNT" ]; then
echo "::error::latest-mac.yml lists $MANIFEST_COUNT .zip file(s) but $ZIP_COUNT .zip(s) were built — auto-update would be broken for whichever architecture's entry is missing"
exit 1
fi
VERIFY_DIR=$(mktemp -d)
trap 'rm -rf "$VERIFY_DIR"' EXIT
for ZIP in release/*.zip; do
APP_DIR="$VERIFY_DIR/$(basename "$ZIP" .zip)"
mkdir -p "$APP_DIR"
ditto -x -k "$ZIP" "$APP_DIR"
APP=$(find "$APP_DIR" -type d -name '*.app' -print -quit)
[ -n "$APP" ] || { echo "::error::no app bundle in $ZIP"; exit 1; }
codesign --verify --deep --strict --verbose=2 "$APP"
spctl --assess --type execute --verbose=4 "$APP"
xcrun stapler validate "$APP"
done
- name: Upload macOS assets to GitHub release
# latest-mac.yml (+ the .zip/.blockmap it points at) is what
# electron-updater's MacUpdater fetches to check for/apply updates —
# DMG alone can't be used for silent background updates. Every
# release before this one shipped without it, so auto-update on Mac
# has 404'd since v1.6.7 (see main.log: "Cannot find latest-mac.yml").
run: |
VERSION=$(node -p "require('./package.json').version")
UPLOAD_ARGS=(--repo "${{ github.repository }}" --tag "$VERSION")
for FILE in release/*.dmg release/*.zip release/*.zip.blockmap "release/${{ needs.create-release.outputs.manifest_prefix }}-mac.yml"; do
UPLOAD_ARGS+=(--file "$FILE")
done
node scripts/release-gate/ensure-release-assets.cjs "${UPLOAD_ARGS[@]}"
env:
GH_TOKEN: ${{ github.token }}
- name: Verify freshly downloaded macOS release artifact
# The "Verify macOS release assets" step above only checks the local
# electron-builder output directory — it can't catch anything that
# goes wrong between build and publish (upload corruption or GitHub's
# own storage). Re-download what was
# just published and run the same signature/Gatekeeper/notarization
# checks against that, so a "clean" local build can't mask a broken
# published release.
run: |
export PATH="/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
VERSION=$(node -p "require('./package.json').version")
DOWNLOAD_DIR=$(mktemp -d)
VERIFY_DIR=$(mktemp -d)
trap 'rm -rf "$DOWNLOAD_DIR" "$VERIFY_DIR"' EXIT
gh release download "${VERSION}" --repo "${{ github.repository }}" --pattern '*.zip' --dir "$DOWNLOAD_DIR"
gh release download "${VERSION}" --repo "${{ github.repository }}" --pattern "${{ needs.create-release.outputs.manifest_prefix }}-mac.yml" --dir "$DOWNLOAD_DIR"
ls "$DOWNLOAD_DIR"/*.zip >/dev/null 2>&1 || { echo "::error::no .zip downloaded from the draft release"; exit 1; }
for ZIP in "$DOWNLOAD_DIR"/*.zip; do
APP_DIR="$VERIFY_DIR/$(basename "$ZIP" .zip)"
mkdir -p "$APP_DIR"
ditto -x -k "$ZIP" "$APP_DIR"
APP=$(find "$APP_DIR" -type d -name '*.app' -print -quit)
[ -n "$APP" ] || { echo "::error::no app bundle in downloaded $ZIP"; exit 1; }
codesign --verify --deep --strict --verbose=2 "$APP"
spctl --assess --type execute --verbose=4 "$APP"
xcrun stapler validate "$APP"
done
env:
GH_TOKEN: ${{ github.token }}
release-windows:
runs-on: windows-latest
needs: create-release
if: needs.create-release.outputs.promotion_only != 'true'
timeout-minutes: 60
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '22'
- name: Install npm dependencies
run: npm ci
- name: Check Windows signing credentials
# Shipping unsigned for now: a new WINDOWS_CERTS cert isn't a simple
# portable .pfx anymore (post-2023 CA/Browser Forum rules put new
# code-signing keys on a hardware token or cloud HSM), so this needs a
# real signing-service integration (e.g. SignPath Foundation's free
# OSS program), not just two secrets. Log which case we're in but
# never fail the build — remove this whole step once that's wired up.
shell: pwsh
env:
CSC_LINK: ${{ secrets.WINDOWS_CERTS }}
CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CERTS_PASSWORD }}
run: |
if ([string]::IsNullOrWhiteSpace($env:CSC_LINK) -or [string]::IsNullOrWhiteSpace($env:CSC_KEY_PASSWORD)) {
Write-Warning "WINDOWS_CERTS/WINDOWS_CERTS_PASSWORD not set — building an UNSIGNED Windows installer. Users will see a SmartScreen 'Windows protected your PC' prompt until this is wired up."
} else {
Write-Host "Windows signing credentials present — signing remains NOT-VERIFIED until release-boundary verification."
}
- name: Build Windows
# Builds both the direct-download NSIS installer and the Microsoft Store
# AppX packages. Partner Center's "MSIX or PWA app" flow accepts appx/msix
# packages, and electron-builder's Store target is still named appx. The
# package.json target list keeps NSIS x64-only while building AppX for
# x64 and arm64, so Windows on Arm devices keep native Store coverage.
# bash, not pwsh: pwsh mangles the -c.publish.channel="..." electron-builder
# CLI override (splits it into a bare `-c` flag plus a positional that
# electron-builder then tries to open as a config file), which failed the
# 3.3.1-beta.1 release build. The mac/linux jobs already run this exact
# invocation under bash successfully.
shell: bash
run: |
npm run build:frontend && npm run build && npx electron-builder --win --publish never --config.npmRebuild=false \
-c.publish.channel="${{ needs.create-release.outputs.manifest_prefix }}"
node scripts/assert-release-artifact-names.cjs release
env:
CSC_LINK: ${{ secrets.WINDOWS_CERTS }}
CSC_KEY_PASSWORD: ${{ secrets.WINDOWS_CERTS_PASSWORD }}
# Empty when the secrets above aren't set — electron-builder just
# builds unsigned in that case. CSC_IDENTITY_AUTO_DISCOVERY stays
# disabled either way so it never picks up an unrelated identity
# (mirrors release-mac; see #168, electron-userland/electron-builder#7515).
CSC_IDENTITY_AUTO_DISCOVERY: false
GOOGLE_DRIVE_CLIENT_ID: ${{ secrets.GOOGLE_DRIVE_CLIENT_ID }}
GOOGLE_DRIVE_CLIENT_SECRET: ${{ secrets.GOOGLE_DRIVE_CLIENT_SECRET }}
- name: Verify Windows release assets
# Same class of bug as macOS's missing latest-mac.yml: electron-updater's
# NSIS updater needs latest.yml + the installer's .blockmap to check for
# and apply updates. Fail loudly here instead of silently shipping a
# release nothing can auto-update from.
run: |
$manifestPrefix = "${{ needs.create-release.outputs.manifest_prefix }}"
$manifestPath = "release\$manifestPrefix.yml"
if (-not (Test-Path $manifestPath)) { Write-Error "$manifestPath is missing — Windows auto-update would be broken for this release"; exit 1 }
if (-not (Get-ChildItem release\*.exe.blockmap -ErrorAction SilentlyContinue)) { Write-Error "no .exe.blockmap in release\ — Windows auto-update would be broken for this release"; exit 1 }
$appxFiles = @(Get-ChildItem release\*.appx -ErrorAction SilentlyContinue)
if ($appxFiles.Count -eq 0) { Write-Error "no .appx in release\ — Microsoft Store submission package is missing"; exit 1 }
# Identity/publisher must match Partner Center's listing exactly or
# Store submission is rejected; version must match package.json or
# a stale/wrong build could be silently submitted. Check both here,
# before anything is uploaded, rather than finding out from Partner
# Center after the fact.
$expectedIdentity = node -p "require('./package.json').build.appx.identityName"
$expectedPublisher = node -p "require('./package.json').build.appx.publisher"
$expectedVersion = node -p "require('./package.json').version.split('-')[0] + '.0'"
Add-Type -AssemblyName System.IO.Compression.FileSystem
$architectures = @{}
foreach ($appx in $appxFiles) {
$zip = [System.IO.Compression.ZipFile]::OpenRead($appx.FullName)
try {
$entry = $zip.GetEntry("AppxManifest.xml")
if ($null -eq $entry) { Write-Error "AppxManifest.xml is missing from $($appx.Name)"; exit 1 }
$reader = New-Object System.IO.StreamReader($entry.Open())
try {
$manifest = $reader.ReadToEnd()
} finally {
$reader.Dispose()
}
} finally {
$zip.Dispose()
}
# Scope attribute extraction to the <Identity> element itself —
# DisplayName/PublisherDisplayName elsewhere in the manifest also
# contain the substring 'Name="...\"', so matching against the
# whole manifest could grab the wrong attribute.
if ($manifest -notmatch '<Identity\s+([^>]+?)/?>') {
Write-Error "Identity element is missing from AppxManifest.xml in $($appx.Name)"
exit 1
}
# Attribute values may be single- or double-quoted (both are
# valid XML) — electron-builder 26.15.3 emits Publisher with
# single quotes while Name/ProcessorArchitecture/Version use
# double quotes. A double-quote-only pattern here false-positives
# "does not match" even when the value is correct (see the 3.2.1
# release-windows failure this was found from).
$identityAttrs = $Matches[1]
if ($identityAttrs -notmatch 'ProcessorArchitecture=["'']([^"'']+)["'']') {
Write-Error "ProcessorArchitecture is missing from AppxManifest.xml in $($appx.Name)"
exit 1
}
$architectures[$Matches[1]] = $true
if ($identityAttrs -notmatch 'Name=["'']([^"'']+)["'']' -or $Matches[1] -ne $expectedIdentity) {
Write-Error "AppX identity in $($appx.Name) does not match expected '$expectedIdentity'"
exit 1
}
if ($identityAttrs -notmatch 'Publisher=["'']([^"'']+)["'']' -or $Matches[1] -ne $expectedPublisher) {
Write-Error "AppX publisher in $($appx.Name) does not match the configured Store publisher"
exit 1
}
if ($identityAttrs -notmatch 'Version=["'']([^"'']+)["'']' -or $Matches[1] -ne $expectedVersion) {
Write-Error "AppX version in $($appx.Name) does not match package.json version '$expectedVersion'"
exit 1
}
}
foreach ($requiredArch in @("x64", "arm64")) {
if (-not $architectures.ContainsKey($requiredArch)) {
Write-Error "Missing $requiredArch AppX package — Microsoft Store release would not cover all supported Windows architectures"
exit 1
}
}
- name: Upload Windows assets to GitHub release
run: |
$VERSION = node -p "require('./package.json').version"
$manifestPrefix = "${{ needs.create-release.outputs.manifest_prefix }}"
$files = @(Get-ChildItem release\*.exe, release\*.exe.blockmap, release\*.appx, "release\$manifestPrefix.yml" | Select-Object -ExpandProperty FullName)
if ($files.Count -eq 0) { throw "No Windows release assets were produced." }
$uploadArgs = @('scripts/release-gate/ensure-release-assets.cjs', '--repo', '${{ github.repository }}', '--tag', $VERSION)
foreach ($file in $files) { $uploadArgs += @('--file', $file) }
& node @uploadArgs
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
env:
GH_TOKEN: ${{ github.token }}
verify-release:
name: Verify uploaded draft release
# No release is publishable until every platform job and this independent
# post-upload verification pass succeeds.
needs: [create-release, release-linux, release-mac, release-windows]
if: needs.create-release.outputs.promotion_only != 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
outputs:
candidate_manifest_asset_id: ${{ steps.verify-assets.outputs.candidate_manifest_asset_id }}
candidate_manifest_sha256: ${{ steps.verify-assets.outputs.candidate_manifest_sha256 }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '22'
- name: Install verifier dependencies
run: npm ci --ignore-scripts --no-audit --no-fund
- name: Create immutable candidate manifest from uploaded draft bytes
# This is deliberately after every platform upload and before any feed
# pointer changes. The manifest binds GitHub asset IDs, names, sizes,
# platform/architecture, SHA-256/SHA-512, and explicit signing status.
run: |
node scripts/release-gate/candidate-manifest.cjs \
--repo "${{ github.repository }}" \
--tag "${{ needs.create-release.outputs.version }}" \
--commit "${{ github.sha }}" \
--channel "${{ needs.create-release.outputs.channel }}" \
--windows-signing-status not-verified \
--output "$RUNNER_TEMP/candidate-manifest.json"
env:
GH_TOKEN: ${{ github.token }}
- name: Attach immutable candidate manifest and permanent sanitized summary
# Never overwrite a candidate manifest on a rerun. A rerun must prove
# that the bytes are identical, otherwise the release remains blocked.
run: |
set -euo pipefail
TAG="${{ needs.create-release.outputs.version }}"
for NAME in candidate-manifest.json release-summary.json; do
LOCAL="$RUNNER_TEMP/$NAME"
if [ "$NAME" = "release-summary.json" ]; then
node scripts/release-gate/evidence.cjs \
--manifest "$RUNNER_TEMP/candidate-manifest.json" \
--output "$LOCAL"
fi
EXISTING_URL=$(gh api "repos/${{ github.repository }}/releases" --paginate \
--jq ".[] | select(.tag_name == \"$TAG\") | .assets[] | select(.name == \"$NAME\") | .url" | head -1 || true)
if [ -n "$EXISTING_URL" ]; then
EXISTING="$RUNNER_TEMP/existing-$NAME"
gh api "$EXISTING_URL" -H 'Accept: application/octet-stream' > "$EXISTING"
cmp -s "$LOCAL" "$EXISTING" || {
echo "::error::$NAME already exists with different bytes; refusing to mutate an immutable release binding"
exit 1
}
else
gh release upload "$TAG" "$LOCAL" --repo "${{ github.repository }}"
fi
done
env:
GH_TOKEN: ${{ github.token }}
- name: Download and verify every release manifest and referenced artifact
# Verification includes the immutable candidate binding, per-arch Snap
# publication markers, and the permanent sanitized summary before the
# draft can become a public feed. Also outputs the candidate manifest
# asset ID and SHA-256 for promote-stable below.
id: verify-assets
run: |
set -euo pipefail
CANDIDATE_MANIFEST_ASSET_ID=$(gh api "repos/${{ github.repository }}/releases" --paginate \
--jq ".[] | select(.tag_name == \"$RELEASE_TAG\") | .assets[] | select(.name == \"candidate-manifest.json\") | .id" | head -1)
if [ -z "$CANDIDATE_MANIFEST_ASSET_ID" ]; then
echo "::error::candidate-manifest.json asset ID is missing; refusing to verify or publish the release"
exit 1
fi
VERIFY_ARGS=(
scripts/verify-release-assets.cjs
--repo "${{ github.repository }}"
--tag "$RELEASE_TAG"
--channel "${{ needs.create-release.outputs.manifest_prefix }}"
--require-candidate-manifest
--require-release-summary
)
if [ "$RELEASE_CHANNEL" = "stable" ]; then
VERIFY_ARGS+=(--require-snap-evidence)
fi
VERIFY_ARGS+=(
--candidate-manifest-asset-id "$CANDIDATE_MANIFEST_ASSET_ID"
--candidate-manifest-commit "${{ github.sha }}"
)
node "${VERIFY_ARGS[@]}"
CANDIDATE_MANIFEST_SHA256=$(gh api "repos/${{ github.repository }}/releases/assets/$CANDIDATE_MANIFEST_ASSET_ID" \
-H 'Accept: application/octet-stream' | sha256sum | cut -d' ' -f1)
{
echo "candidate_manifest_asset_id=$CANDIDATE_MANIFEST_ASSET_ID"
echo "candidate_manifest_sha256=$CANDIDATE_MANIFEST_SHA256"
} >> "$GITHUB_OUTPUT"
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ needs.create-release.outputs.version }}
RELEASE_CHANNEL: ${{ needs.create-release.outputs.channel }}
publish-release:
name: Publish verified release
# Keep this as a distinct job so publishing cannot happen in a platform
# job before the other platforms' draft assets have been checked.
needs: [create-release, release-linux, release-mac, release-windows, verify-release]
if: needs.create-release.outputs.promotion_only != 'true' && needs.verify-release.result == 'success'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: '22'
- name: Publish draft without changing GitHub Latest by default
run: |
set -euo pipefail
VERSION="${{ needs.create-release.outputs.version }}"
PRERELEASE="${{ needs.create-release.outputs.prerelease }}"
CHANNEL="${{ needs.create-release.outputs.channel }}"
# Draft releases are not addressable via /releases/tags/{tag} (that
# endpoint only matches published releases), so resolve the draft's
# ID through the releases list. Same root cause as the draft-asset
# verifier fix in scripts/verify-release-assets.cjs (#468).
RELEASE_ID=$(gh api "repos/${{ github.repository }}/releases" --paginate \
--jq ".[] | select(.tag_name==\"$VERSION\") | .id" | head -1)
if [ -z "$RELEASE_ID" ]; then
echo "::error::No release found for tag $VERSION"
exit 1
fi
# Beta publishing must not change Stable Latest; capture the current
# Latest before publishing so the readiness check can assert
# immutability. Stable publishing skips this assertion because GitHub's
# /releases/latest returns the newest published non-prerelease by
# created_at, making the assertion impossible for new stable releases.
READINESS_ARGS=(
--repo "${{ github.repository }}"
--tag "$VERSION"
--commit "${{ github.sha }}"
--channel "$CHANNEL"
)
if [ "$CHANNEL" = "beta" ]; then
if LATEST_RESPONSE=$(gh api "repos/${{ github.repository }}/releases/latest" --jq .tag_name --include 2>&1); then
LATEST_BEFORE=$(printf '%s\n' "$LATEST_RESPONSE" | tail -n 1)
elif grep -qE '^HTTP/[^ ]+ 404 ' <<<"$LATEST_RESPONSE"; then
LATEST_BEFORE=""
else
printf '%s\n' "$LATEST_RESPONSE" >&2
exit 1