-
Notifications
You must be signed in to change notification settings - Fork 0
1295 lines (1210 loc) · 63.8 KB
/
Copy pathrelease.yml
File metadata and controls
1295 lines (1210 loc) · 63.8 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
# Phase 8 bundling. A tag push `v*` builds + uploads the release artifacts:
# * Roost-<version>.dmg (macOS, arm64)
# * Roost-Iced-<version>.dmg (macOS, arm64 — experimental)
# * roost_<version>_amd64.deb / _arm64.deb (Linux, native per-arch)
# then fires repository_dispatch at charliek/apt-charliek so the .deb lands in
# the apt repo. The GTK / ghostty-cache patterns mirror ci.yml.
#
# Mac signing/notarization is GATED on secrets and inert until they're added:
# ROOST_DEVELOPER_ID_IDENTITY, MACOS_CERTIFICATE_P12_BASE64,
# MACOS_CERTIFICATE_PASSWORD, APPLE_ID, APPLE_TEAM_ID,
# APPLE_APP_SPECIFIC_PASSWORD. Without them the DMG ships ad-hoc-signed
# (Gatekeeper requires right-click → Open).
#
# The Release is created as a DRAFT and stays one until `publish-release`
# flips it, after both .debs and the DMG are attached, non-empty and named
# for the tag. Publishing up front let two things go wrong: a failed build
# left a public, empty release behind, and apt-charliek could consume it —
# its collect-debs.sh walks releases newest-first and only *warns* past one
# that carries no matching .deb, so it silently republishes the PREVIOUS
# version. A draft is invisible to it (GitHub lists drafts only to callers
# with push access, which its repo-scoped GITHUB_TOKEN doesn't have).
#
# Sparkle auto-update (issue #122): the mac job EdDSA-signs the DMG with
# SPARKLE_ED_PRIVATE_KEY and hands sign.txt forward; the `appcast` job then
# appends an entry to docs/appcast.xml and pushes that commit to main as the
# charliek-release-bot GitHub App. The App is in main's ruleset bypass list
# (the only way to push past the ci-success requirement on a bot-authored
# commit — see #136 for the GITHUB_TOKEN attempt that failed). docs.yml then
# redeploys Pages → appcast live. No manual post-release step. That job runs
# after publish-release because the enclosure URL it writes is a public
# /releases/download/ link, which a draft's assets don't answer.
# `mac-iced` + `appcast-iced` are the same two-part shape for the
# experimental Roost-Iced build, on a SEPARATE feed (docs/appcast-iced.xml)
# with a SEPARATE keypair (mac/keys/README.md) — the two apps must never be
# able to offer each other's updates.
on:
push:
tags:
- 'v*'
# Least privilege: default read; only the release-writing jobs get contents:write.
permissions:
contents: read
# Serialize release runs against the same tag: rare in practice (tags are unique),
# but a re-run via the Actions UI while the original is still building would
# otherwise race on the appcast push. `cancel-in-progress: false` keeps both
# runs alive — better to wait than to abort half-uploaded artifacts.
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
version-check:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- name: Assert tag matches [workspace.package] version
run: |
set -euo pipefail
tag="${GITHUB_REF_NAME}"
tag_ver="${tag#v}"
base_ver="${tag_ver%%-*}" # strip any -rc1/-beta suffix
cargo_ver="$(grep -E '^version[[:space:]]*=' Cargo.toml | head -1 \
| sed -E 's/^version[[:space:]]*=[[:space:]]*"([^"]+)".*/\1/')"
echo "tag=${tag} (base ${base_ver}) Cargo.toml workspace version=${cargo_ver}"
if [ "${base_ver}" != "${cargo_ver}" ]; then
echo "::error::tag ${tag} does not match [workspace.package] version ${cargo_ver}. Bump Cargo.toml before tagging."
exit 1
fi
# Don't ship a tag whose code never passed CI. `ci-success` is the single
# required check on `main`; the tagged commit (pushed to main moments
# earlier by /release:release) gets its own ci.yml run, so poll that
# commit's `ci-success` check and refuse to release unless it's green.
# Poll because that run may still be in flight when the tag fires.
ci-gate:
runs-on: ubuntu-latest
timeout-minutes: 55
permissions:
contents: read
checks: read
steps:
- name: Require ci-success green on the tagged commit
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
deadline=$(( SECONDS + 2700 )) # 45 min — releases aren't time-sensitive
while :; do
# Server-side check_name filter. Without it, the endpoint returns
# ≤30 runs per page; on busy commits ci-success can fall off the
# first page and the loop would spin until timeout looking for
# a check that exists.
latest="$(gh api \
"repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/check-runs?check_name=ci-success" \
--jq '.check_runs | sort_by(.started_at) | last')"
status="$(echo "${latest}" | jq -r '.status // "missing"')"
conclusion="$(echo "${latest}" | jq -r '.conclusion // ""')"
echo "ci-success @ ${GITHUB_SHA}: status=${status} conclusion=${conclusion}"
if [ "${status}" = "completed" ]; then
[ "${conclusion}" = "success" ] && { echo "CI green — releasing."; exit 0; }
echo "::error::ci-success concluded '${conclusion}' for ${GITHUB_SHA} — not releasing."
exit 1
fi
if [ "${SECONDS}" -ge "${deadline}" ]; then
echo "::error::timed out waiting for ci-success on ${GITHUB_SHA} (status=${status}). Push the commit to main and let CI finish before tagging."
exit 1
fi
sleep 20
done
create-release:
needs: [version-check, ci-gate]
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- name: Create (or reuse) the draft GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
tag="${GITHUB_REF_NAME}"
pre=""
case "${tag}" in *-*) pre="--prerelease";; esac
# Pull this tag's section out of CHANGELOG.md (## <tag> … up to the
# next ## v…). Field match so a trailing "— <date>" doesn't break it.
notes="$(awk -v tag="${tag}" '
$1=="##" && $2==tag {flag=1; next}
/^## v/ {if(flag) exit}
flag {print}
' CHANGELOG.md || true)"
if gh release view "${tag}" >/dev/null 2>&1; then
# Reuse is for the re-run case, where the earlier attempt left a
# draft behind. Reusing a PUBLISHED release is not: the build jobs
# would `--clobber` new assets straight into something users and
# apt-charliek can already see, mid-run. Fail closed and make a
# human decide (RELEASING.md covers the recovery).
if [ "$(gh release view "${tag}" --json isDraft -q .isDraft)" != "true" ]; then
echo "::error::Release ${tag} already exists and is PUBLISHED. Refusing to rebuild into a live release — delete it (gh release delete ${tag}) or cut a new tag."
exit 1
fi
echo "Draft release ${tag} already exists — reusing."
exit 0
fi
# --draft: no artifacts are attached yet. publish-release flips it.
# A draft does NOT create or move a git tag, and `gh release upload
# --clobber` works against it (verified empirically before this
# change landed), so the build jobs need no other adjustment.
if [ -n "${notes}" ]; then
printf '%s\n' "${notes}" | gh release create "${tag}" --draft --title "${tag}" --notes-file - ${pre}
else
gh release create "${tag}" --draft --title "${tag}" --generate-notes ${pre}
fi
linux:
needs: create-release
permissions:
contents: write
strategy:
fail-fast: false
matrix:
# `label` is the asset's display name on the Release page. Debian
# requires the file itself be `<pkg>_<version>_<arch>.deb` (apt keys
# off it), but "amd64" and "arm64" are near-identical at a glance in
# adjacent rows, so the label spells the target out. The download
# still lands as the real filename.
include:
- { runner: ubuntu-24.04, arch: amd64, label: "Linux — Intel / AMD 64-bit (.deb)" }
- { runner: ubuntu-24.04-arm, arch: arm64, label: "Linux — ARM 64-bit (.deb)" }
runs-on: ${{ matrix.runner }}
timeout-minutes: 90
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
# The .deb ships the iced UI as /usr/bin/roost (linux/scripts/
# build-deb.sh), not roost-linux — GTK dev packages (libgtk-4-dev,
# libadwaita-1-dev) are dead weight now that the package no longer
# contains the GTK UI. pkg-config + libclang-dev build roost-vt's
# bindgen; the rest mirrors ci.yml's `iced-release` job — the display
# + wgpu/vulkan stack the packaged-artifact smoke below needs to
# actually launch the binary under Xvfb.
- name: Install Iced + build deps
run: |
sudo apt-get update
sudo apt-get install -y \
pkg-config \
libclang-dev \
fonts-noto-cjk \
libxkbcommon-x11-0 \
libwayland-client0 \
mesa-vulkan-drivers \
xvfb \
xdotool \
zsh \
desktop-file-utils
- name: Install nfpm
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
ver="2.46.3"
case "${{ matrix.arch }}" in
amd64) nfpm_arch="x86_64" ;;
arm64) nfpm_arch="arm64" ;;
esac
url="https://github.com/goreleaser/nfpm/releases/download/v${ver}/nfpm_${ver}_Linux_${nfpm_arch}.tar.gz"
curl -fsSL "${url}" -o /tmp/nfpm.tgz
sudo tar -C /usr/local/bin -xzf /tmp/nfpm.tgz nfpm
nfpm --version
- name: Install Rust toolchain (from rust-toolchain.toml)
uses: actions-rust-lang/setup-rust-toolchain@v1
- uses: jdx/mise-action@v4
- name: Cache vendored libghostty-vt
id: cache-ghostty
uses: actions/cache@v5
with:
path: |
third_party/ghostty/out
third_party/ghostty/src
key: vendored-ghostty-${{ runner.os }}-${{ matrix.arch }}-${{ hashFiles('third_party/ghostty/build.sh') }}
- name: Build libghostty-vt
if: steps.cache-ghostty.outputs.cache-hit != 'true'
run: ./third_party/ghostty/build.sh
- name: Cache cargo registry + target
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-release-${{ runner.os }}-${{ matrix.arch }}-${{ hashFiles('**/Cargo.toml', 'rust-toolchain.toml') }}
restore-keys: cargo-release-${{ runner.os }}-${{ matrix.arch }}-
- name: Build .deb
run: ./linux/scripts/build-deb.sh "${GITHUB_REF_NAME#v}"
# This is the only thing in the release path that actually launches the
# artifact being shipped. The assertions live in linux/scripts/
# smoke-deb.sh (payload contents, control metadata, and — the real
# point — that the packaged binary's compiled-in default lands on the
# production `roost` IPC namespace) so the same code can run on
# ordinary PRs in ci.yml instead of first executing during a release.
# `--expect-version` gets the nfpm-normalized string: nfpm rewrites `-`
# to `~` in Debian versions (v0.0.18-rc1 -> 0.0.18~rc1).
- name: Smoke the packaged artifact
run: |
set -euo pipefail
deb="$(./linux/scripts/resolve-one-deb.sh out)"
# Replace only the FIRST `-`, matching nfpm's semver schema: it
# splits version from prerelease at the first hyphen and joins them
# with `~`, leaving any later hyphen in the prerelease intact
# (v1.2.3-rc-2 -> 1.2.3~rc-2). A blanket `tr '-' '~'` would produce
# 1.2.3~rc~2 and fail this assertion *after* create-release has
# already published the Release.
raw="${GITHUB_REF_NAME#v}"
ver="${raw/-/\~}"
./linux/scripts/smoke-deb.sh "${deb}" \
--work-dir "${RUNNER_TEMP}/roost-smoke" \
--expect-version "${ver}"
# The step above extracts the .deb, so it validates the payload but NOT
# the dependency closure: this runner already carries the whole graphics
# stack from the build step, so a `Depends:` line that forgot a library
# would still launch here. See the script for the rest of the reasoning.
- name: Verify the dependency closure in a clean container
run: |
set -euo pipefail
deb="$(./linux/scripts/resolve-one-deb.sh out)"
./linux/scripts/verify-deb-closure.sh "${deb}"
- name: Upload .deb to the Release
env:
GH_TOKEN: ${{ github.token }}
ASSET_LABEL: ${{ matrix.label }}
ARCH: ${{ matrix.arch }}
run: |
set -euo pipefail
# Match on the arch this job's label claims, not any .deb: the
# `file#label` syntax needs one concrete path, and a bare glob would
# happily hand an arm64 package to the job that labels it Intel/AMD
# — the exact mislabeling the labels exist to prevent. resolve-one-deb
# also means a build that emitted nothing fails here, naming the arch,
# instead of passing an unexpanded glob down to gh.
deb="$(./linux/scripts/resolve-one-deb.sh out --arch "${ARCH}")"
gh release upload "${GITHUB_REF_NAME}" "${deb}#${ASSET_LABEL}" --clobber
mac:
needs: create-release
runs-on: macos-26 # arm64 — pinned; ships Xcode 26 by default, which
timeout-minutes: 90
# actool needs to compile mac/AppIcon.icon (the Tahoe
# glass icon). On older Xcode, bundle.sh still builds but
# falls back to the flat (framed) .icns. See
# packaging/icon/README.md.
permissions:
contents: write
env:
# Sign + notarize only when the FULL prerequisite set is present. Any
# missing secret → ad-hoc fallback: a Developer-ID-signed but un-notarized
# DMG is still Gatekeeper-blocked, so signing without notarizing has no
# value, and a partial secret set must not break the build or silently
# drop the first-launch note. The identity is handed to the bundle/DMG
# steps (below) only when this is true.
CAN_NOTARIZE: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 != '' && secrets.MACOS_CERTIFICATE_PASSWORD != '' && secrets.ROOST_DEVELOPER_ID_IDENTITY != '' && secrets.APPLE_ID != '' && secrets.APPLE_TEAM_ID != '' && secrets.APPLE_APP_SPECIFIC_PASSWORD != '' }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- name: Install Rust toolchain (from rust-toolchain.toml)
uses: actions-rust-lang/setup-rust-toolchain@v1
- uses: jdx/mise-action@v4
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Cache vendored libghostty-vt
id: cache-ghostty
uses: actions/cache@v5
with:
path: |
third_party/ghostty/out
third_party/ghostty/src
key: vendored-ghostty-${{ runner.os }}-${{ hashFiles('third_party/ghostty/build.sh') }}
- name: Build libghostty-vt
if: steps.cache-ghostty.outputs.cache-hit != 'true'
run: ./third_party/ghostty/build.sh
- name: Cache SwiftPM artifacts
uses: actions/cache@v5
with:
path: |
mac/.build
~/Library/Caches/org.swift.swiftpm
key: swiftpm-release-${{ runner.os }}-${{ hashFiles('mac/Package.swift', 'mac/Package.resolved') }}
restore-keys: swiftpm-release-${{ runner.os }}-
- name: Cache cargo registry + target
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-release-${{ runner.os }}-${{ hashFiles('**/Cargo.toml', 'rust-toolchain.toml') }}
restore-keys: cargo-release-${{ runner.os }}-
# Inert until all six Apple secrets exist (the CAN_NOTARIZE gate). Imports
# the Developer ID Application cert into a throwaway keychain so codesign
# can use it.
- name: Import Developer ID certificate
if: env.CAN_NOTARIZE == 'true'
env:
MACOS_CERTIFICATE_P12_BASE64: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }}
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
run: |
set -euo pipefail
# The decoded cert must never outlive this step, even on early failure.
# (The keychain itself must persist — later steps sign against it.)
trap 'rm -f "$RUNNER_TEMP/cert.p12"' EXIT
kc="$RUNNER_TEMP/roost-signing.keychain-db"
kp="$(openssl rand -base64 24)"
security create-keychain -p "$kp" "$kc"
security set-keychain-settings -lut 21600 "$kc"
security unlock-keychain -p "$kp" "$kc"
echo "$MACOS_CERTIFICATE_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12"
security import "$RUNNER_TEMP/cert.p12" -k "$kc" -P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple: -s -k "$kp" "$kc"
# Prepend our keychain to the user search list, keeping the existing
# ones; the command substitution must word-split into separate paths.
# shellcheck disable=SC2046
security list-keychains -d user -s "$kc" $(security list-keychains -d user | sed 's/"//g')
rm -f "$RUNNER_TEMP/cert.p12"
# Guard (issue #122): a STABLE release must not ship the throwaway spike
# SUPublicEDKey — builds signed with the real key would fail to verify
# against it, and users on a throwaway-key build can't auto-update. A
# prerelease (-beta/-rc) may keep it to validate the appcast round-trip
# with the matching throwaway private key. Mirrors create-release's
# `case "$tag" in *-*)` prerelease idiom.
- name: Guard against the throwaway Sparkle key on stable releases
run: |
set -euo pipefail
case "${GITHUB_REF_NAME}" in
*-*) echo "prerelease ${GITHUB_REF_NAME} — throwaway SUPublicEDKey allowed for validation."; exit 0 ;;
esac
throwaway='9n43stvrJeqWENXHYrisnIhByQ8N0OcjzSWAvhZj4fo='
if grep -q "${throwaway}" mac/Resources/Info.plist.template; then
echo "::error::mac/Resources/Info.plist.template still carries the THROWAWAY SUPublicEDKey (#122). Swap it for the maintainer's real public key and set SPARKLE_ED_PRIVATE_KEY before cutting a stable release."
exit 1
fi
echo "SUPublicEDKey is not the throwaway placeholder — ok."
# The identity reaches bundle.sh / make-dmg.sh only when CAN_NOTARIZE —
# otherwise empty, so they take the ad-hoc path (and make-dmg keeps the
# FIRST-LAUNCH note).
- name: Bundle Roost.app
env:
ROOST_DEVELOPER_ID_IDENTITY: ${{ env.CAN_NOTARIZE == 'true' && secrets.ROOST_DEVELOPER_ID_IDENTITY || '' }}
run: ROOST_VERSION="${GITHUB_REF_NAME#v}" ./mac/scripts/bundle.sh release
# Validate the *actual shipped artifact*: drive the release bundle
# through the E2E op set before it's packaged. ci-gate already
# confirmed the commit's required checks are green, but a version-bump
# commit can path-filter e2e-mac out of that run — this guarantees the
# binary in the DMG passes E2E regardless. Reuses the harness's
# pre-launch hygiene + scaled timeouts.
- name: E2E against the release bundle
env:
ROOST_TEST_TIMEOUT_SCALE: "3"
# Match the e2e-mac CI job's surface so the release bundle is
# exercised identically (test-only ops unlocked). --roost-fresh
# owns a hermetic instance with an isolated, throwaway state dir.
ROOST_TEST_MODE: "1"
run: uv run --group test pytest tools/roosttest --roost-target mac --roost-fresh -v
- name: Make DMG
env:
ROOST_DEVELOPER_ID_IDENTITY: ${{ env.CAN_NOTARIZE == 'true' && secrets.ROOST_DEVELOPER_ID_IDENTITY || '' }}
run: ./mac/scripts/make-dmg.sh "${GITHUB_REF_NAME#v}"
- name: Notarize + staple (skipped without credentials)
if: env.CAN_NOTARIZE == 'true'
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
run: ./mac/scripts/notarize.sh "mac/build/Roost-${GITHUB_REF_NAME#v}.dmg"
# What governs Gatekeeper on a downloaded DMG is whether it's *notarized*
# (a stapled ticket) — not whether a signing cert exists. A Developer-ID-
# signed but un-notarized DMG is still blocked. So detect the real artifact
# state with `stapler validate` and gate the first-launch guidance on it;
# this can't drift out of sync with notarize.sh's credential checks.
- name: Detect DMG notarization status
id: dmg_notarized
run: |
if xcrun stapler validate "mac/build/Roost-${GITHUB_REF_NAME#v}.dmg" >/dev/null 2>&1; then
echo "value=true" >> "$GITHUB_OUTPUT"
else
echo "value=false" >> "$GITHUB_OUTPUT"
fi
- name: Note signing status
if: steps.dmg_notarized.outputs.value != 'true'
run: |
echo "::notice::Roost-${GITHUB_REF_NAME#v}.dmg is NOT notarized — Gatekeeper blocks first launch. Clear it with 'xattr -dr com.apple.quarantine /Applications/Roost.app', or System Settings > Privacy & Security > Open Anyway."
# The `#...` suffix sets the asset's display name on the Release page;
# the file still downloads as Roost-<version>.dmg, which is what the
# Sparkle appcast's enclosure URL is built from.
- name: Upload DMG to the Release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release upload "${GITHUB_REF_NAME}" \
"mac/build/Roost-${GITHUB_REF_NAME#v}.dmg#macOS (.dmg)" --clobber
# Sparkle appcast, part 1 of 2 (issue #122). The signing happens HERE
# because this job is the only one holding the Sparkle SPM artifacts
# (which carry sign_update) under mac/.build/artifacts — signing
# elsewhere would mean re-bootstrapping the whole Swift build. Writing
# the appcast is part 2, in the `appcast` job, which can't run until the
# Release is published: the enclosure URL is a public
# /releases/download/ link and a draft's assets don't answer it.
- name: Sign the DMG for Sparkle
env:
SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }}
run: |
set -euo pipefail
# Fail fast on missing config BEFORE doing any work.
if [ -z "${SPARKLE_ED_PRIVATE_KEY}" ]; then
echo "::error::SPARKLE_ED_PRIVATE_KEY secret is unset — can't sign the appcast entry."
exit 1
fi
VER="${GITHUB_REF_NAME#v}"
DMG="mac/build/Roost-${VER}.dmg"
test -s "${DMG}" || { echo "::error::${DMG} missing — the DMG steps must run first."; exit 1; }
SIGN_UPDATE="$(find mac/.build/artifacts -path '*/bin/sign_update' -type f -not -path '*old_dsa*' | head -1)"
if [ -z "${SIGN_UPDATE}" ]; then
echo "::error::sign_update not found under mac/.build/artifacts. Did Bundle Roost.app (which triggers swift build) run?"
exit 1
fi
W="$(mktemp -d)"
chmod 700 "$W"
trap 'rm -rf "$W"' EXIT
printf '%s' "${SPARKLE_ED_PRIVATE_KEY}" | base64 --decode > "${W}/key"
mkdir -p "${RUNNER_TEMP}/appcast"
"${SIGN_UPDATE}" --ed-key-file "${W}/key" "${DMG}" > "${RUNNER_TEMP}/appcast/sign.txt"
rm -f "${W}/key"
# The whole line is what travels to the appcast job: it carries both
# sparkle:edSignature AND length, and update-appcast.py needs both to
# build the <enclosure>. Not a secret — both end up in the public feed.
cat "${RUNNER_TEMP}/appcast/sign.txt"
- name: Hand sign.txt to the appcast job
uses: actions/upload-artifact@v7
with:
name: sparkle-sign
path: ${{ runner.temp }}/appcast/sign.txt
if-no-files-found: error
# Artifacts are immutable, so without this a re-run of just the mac
# job (e.g. after the release-body step failed) dies on the existing
# name instead of recovering.
overwrite: true
# Interim only: while the DMG isn't notarized, append the Gatekeeper-bypass
# instructions to the release body so downloaders see them at the earliest
# touchpoint. Gated on the stapled-ticket check above, so it self-disables
# once notarization lands. A sentinel marker keeps re-runs idempotent.
- name: Append macOS first-launch note to release body
if: steps.dmg_notarized.outputs.value != 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
tag="${GITHUB_REF_NAME}"
marker="<!-- roost-macos-firstlaunch -->"
body="$(gh release view "${tag}" --json body -q .body)"
case "${body}" in
*"${marker}"*) echo "First-launch note already present — skipping."; exit 0 ;;
esac
{
printf '%s\n\n' "${body}"
printf '%s\n' "${marker}"
printf '### macOS first launch\n\n'
printf 'The macOS DMG is not yet notarized (pending an Apple Developer account), so Gatekeeper blocks the first launch. Clear it once after dragging Roost to Applications:\n\n'
printf '```\nxattr -dr com.apple.quarantine /Applications/Roost.app\n```\n\n'
printf 'Or, on macOS 15+, use **System Settings → Privacy & Security → Open Anyway**. Full details: [Installation → First launch on macOS](https://github.com/charliek/roost/blob/main/docs/getting-started/installation.md#first-launch-on-macos).\n'
} > "${RUNNER_TEMP}/relbody.md"
gh release edit "${tag}" --notes-file "${RUNNER_TEMP}/relbody.md"
# The experimental Roost-Iced macOS build (plan 030). Mirrors `mac` above —
# same runner, same six-secret CAN_NOTARIZE gate, same certificate import,
# same notarize/sign/upload shape — with three deliberate carve-outs:
#
# * No SwiftPM cache: this bundle is a `cargo build`, not `swift build`,
# and no mac/.build exists here (which is also why the Sparkle signing
# step below uses the vendored third_party/sparkle/out/bin/sign_update
# instead of the SwiftPM artifact path the `mac` job finds).
# * No Swift throwaway-key grep: superseded by the key-file fail-fast
# below, which is strictly stronger (it also checks the secret).
# * No first-launch-note release-body edit: two jobs read-modify-writing
# the same release body would race, and the note already ships INSIDE
# the DMG as FIRST-LAUNCH.txt (make-dmg.sh writes it whenever there is
# no Developer ID identity).
mac-iced:
needs: create-release
runs-on: macos-26 # Same pin as `mac`: bundle-iced.sh shares that job's
timeout-minutes: 90 # icon pipeline (bundle-lib.sh), which wants Xcode 26.
permissions:
contents: write
env:
CAN_NOTARIZE: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 != '' && secrets.MACOS_CERTIFICATE_PASSWORD != '' && secrets.ROOST_DEVELOPER_ID_IDENTITY != '' && secrets.APPLE_ID != '' && secrets.APPLE_TEAM_ID != '' && secrets.APPLE_APP_SPECIFIC_PASSWORD != '' }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- name: Install Rust toolchain (from rust-toolchain.toml)
uses: actions-rust-lang/setup-rust-toolchain@v1
- uses: jdx/mise-action@v4
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Cache vendored libghostty-vt
id: cache-ghostty
uses: actions/cache@v5
with:
path: |
third_party/ghostty/out
third_party/ghostty/src
key: vendored-ghostty-${{ runner.os }}-${{ hashFiles('third_party/ghostty/build.sh') }}
- name: Build libghostty-vt
if: steps.cache-ghostty.outputs.cache-hit != 'true'
run: ./third_party/ghostty/build.sh
# Own cache key, not the `mac` job's `cargo-release-*`: these two jobs
# run in parallel and would otherwise both try to SAVE the same key.
- name: Cache cargo registry + target
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-release-iced-${{ runner.os }}-${{ hashFiles('**/Cargo.toml', 'rust-toolchain.toml') }}
restore-keys: cargo-release-iced-${{ runner.os }}-
# Deliberately uncached (unlike ci.yml's iced lanes): fetch.sh
# re-verifies the pinned SHA256 on every download, and the release path
# is the one place that check is worth paying for. bundle-iced.sh would
# call it anyway; running it up front also guarantees
# out/bin/sign_update exists for the signing step below.
- name: Stage Sparkle
run: ./third_party/sparkle/fetch.sh
# Same throwaway keychain the `mac` job builds, for the same reason.
- name: Import Developer ID certificate
if: env.CAN_NOTARIZE == 'true'
env:
MACOS_CERTIFICATE_P12_BASE64: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }}
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
run: |
set -euo pipefail
# The decoded cert must never outlive this step, even on early failure.
# (The keychain itself must persist — later steps sign against it.)
trap 'rm -f "$RUNNER_TEMP/cert.p12"' EXIT
kc="$RUNNER_TEMP/roost-signing.keychain-db"
kp="$(openssl rand -base64 24)"
security create-keychain -p "$kp" "$kc"
security set-keychain-settings -lut 21600 "$kc"
security unlock-keychain -p "$kp" "$kc"
echo "$MACOS_CERTIFICATE_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12"
security import "$RUNNER_TEMP/cert.p12" -k "$kc" -P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple: -s -k "$kp" "$kc"
# Prepend our keychain to the user search list, keeping the existing
# ones; the command substitution must word-split into separate paths.
# shellcheck disable=SC2046
security list-keychains -d user -s "$kc" $(security list-keychains -d user | sed 's/"//g')
rm -f "$RUNNER_TEMP/cert.p12"
# BEFORE the build, because every one of these failures is a config
# mistake that would otherwise surface 40 minutes later, at signing
# time — or worse, not at all: a bundle stamped with a public key whose
# private half never signed anything looks perfect and simply never
# updates. mac/keys/README.md is the install procedure this points at.
- name: Verify the iced Sparkle key material
env:
ICED_PRIVATE_KEY: ${{ secrets.ROOST_ICED_SPARKLE_ED_PRIVATE_KEY }}
run: |
set -euo pipefail
key_file="mac/keys/roost-iced-sparkle-ed-public-key.txt"
fixture="tools/roosttest/fixtures/sparkle/TEST-ONLY-public-ed-key.txt"
if [ ! -s "${key_file}" ]; then
echo "::error::${key_file} is missing or empty — generate the Roost-Iced Sparkle keypair and commit its public half before tagging (mac/keys/README.md)."
exit 1
fi
if [ ! -f "${fixture}" ]; then
echo "::error::${fixture} not found — the TEST-ONLY-key guard can no longer compare against it; update this step's path."
exit 1
fi
if cmp -s "${key_file}" "${fixture}"; then
echo "::error::${key_file} is the TEST-ONLY fixture key (${fixture}) — a shipped feed must carry the maintainer's real public key (mac/keys/README.md)."
exit 1
fi
# The committed public half must itself be base64 of a 32-byte
# ed25519 public key — a malformed value would be PlistBuddy-stamped
# verbatim and produce a bundle that looks fine and never updates.
pub_bytes="$(base64 --decode < "${key_file}" 2>/dev/null | wc -c | tr -d ' ')"
if [ "${pub_bytes}" != "32" ]; then
echo "::error::${key_file} does not decode to a 32-byte ed25519 public key (got ${pub_bytes} bytes) — re-run the export in mac/keys/README.md."
exit 1
fi
if [ -z "${ICED_PRIVATE_KEY}" ]; then
echo "::error::ROOST_ICED_SPARKLE_ED_PRIVATE_KEY secret is unset — the iced DMG can't be signed for appcast-iced (mac/keys/README.md)."
exit 1
fi
W="$(mktemp -d)"
chmod 700 "$W"
trap 'rm -rf "$W"' EXIT
# Two layers, matching the SPARKLE_ED_PRIVATE_KEY convention: the
# secret is base64 of the key FILE, whose own contents are base64 of
# the ed25519 seed. Decode both so a mangled paste fails here.
# macOS `base64 --decode` is lenient (exit 0 on garbage), so a
# mangled paste is caught by the byte-count below rather than the
# decode calls — the error message covers both.
printf '%s' "${ICED_PRIVATE_KEY}" | base64 --decode > "${W}/key" 2>/dev/null || true
seed_bytes="$(base64 --decode < "${W}/key" 2>/dev/null | wc -c | tr -d ' ')"
# 32 = the ed25519 seed generate_keys writes today; 96 = the older
# format. sign_update accepts exactly these two (its own error
# message claims 64/96, but its code takes 32/96 — verified against
# the vendored 2.9.5).
case "${seed_bytes}" in
32|96) ;;
*)
echo "::error::ROOST_ICED_SPARKLE_ED_PRIVATE_KEY does not decode to a Sparkle ed25519 key (expected 32 or 96 bytes after both base64 layers, got ${seed_bytes}) — set it to \`base64 < <the file generate_keys -x wrote>\` (mac/keys/README.md)."
exit 1
;;
esac
# The two halves are validated independently above, which cannot
# catch the failure that matters most: a secret holding a DIFFERENT
# valid key than the committed public half. That ships a bundle
# whose SUPublicEDKey rejects every signature we produce, and
# Sparkle reports it to users as nothing at all — just "no update".
# Sparkle's own `sign_update --verify` takes a private key, so it
# cannot prove the pair; derive the public half from the seed
# instead. 96-byte legacy keys carry their own public half and are
# not derivable this way, so they are skipped rather than guessed.
if [ "${seed_bytes}" = "32" ]; then
derive='import base64,sys; from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey as K; from cryptography.hazmat.primitives.serialization import Encoding,PublicFormat; print(base64.b64encode(K.from_private_bytes(sys.stdin.buffer.read()).public_key().public_bytes(Encoding.Raw,PublicFormat.Raw)).decode())'
# --no-project: without it uv syncs the repo's docs project first, so a
# stale uv.lock or an unreachable theme git tag would fail the KEY
# check with a message about keys. Pinned for the same reason the
# rest of the release path is pinned.
derived="$(base64 --decode < "${W}/key" | uv run --no-project --with 'cryptography==46.0.3' python3 -c "${derive}")"
if [ "${derived}" != "$(tr -d '[:space:]' < "${key_file}")" ]; then
echo "::error::ROOST_ICED_SPARKLE_ED_PRIVATE_KEY is not the private half of ${key_file}. Signing with it would ship updates no installed Roost-Iced can verify. Re-run the ceremony in mac/keys/README.md."
exit 1
fi
echo "keypair match confirmed (derived public half equals the committed one)"
fi
rm -f "${W}/key"
echo "iced Sparkle key material looks well-formed (public key committed, private secret decodes to ${seed_bytes} bytes)."
# Unlike the Swift bundle (whose SUFeedURL/SUPublicEDKey are baked into
# Info.plist.template), the iced bundle ships feedless by default and
# gets the pair injected here — this env pair IS the feed enablement.
- name: Bundle Roost-Iced.app
env:
ROOST_DEVELOPER_ID_IDENTITY: ${{ env.CAN_NOTARIZE == 'true' && secrets.ROOST_DEVELOPER_ID_IDENTITY || '' }}
ROOST_ICED_SPARKLE_FEED_URL: https://charliek.github.io/roost/appcast-iced.xml
run: |
set -euo pipefail
ROOST_VERSION="${GITHUB_REF_NAME#v}" \
ROOST_ICED_SPARKLE_ED_PUBLIC_KEY="$(cat mac/keys/roost-iced-sparkle-ed-public-key.txt)" \
./mac/scripts/bundle-iced.sh release
# The iced twin of the `mac` job's release-bundle E2E, deliberately a
# cheap subset: the point is that the binary we are about to ship gets
# launched at least once in the release path (boot, IPC, the Sparkle
# seam), not a second full functional suite. test_sparkle's bundle lane
# points the updater at a loopback feed via the test-mode delegate
# override, so the real feed URL stamped above is not consulted here.
- name: E2E against the release bundle
env:
ROOST_TEST_TIMEOUT_SCALE: "3"
ROOST_TEST_MODE: "1"
ROOST_ICED_APP: mac/build/Roost-Iced.app
run: >
uv run --group test pytest
tools/roosttest/test_smoke.py
tools/roosttest/test_sparkle.py
--roost-target iced --roost-fresh -v
# The overrides are what keep this DMG's contents named Roost-Iced.app:
# with defaults make-dmg.sh packages the Swift bundle, and an iced DMG
# containing a "Roost.app" would drag-install OVER the Swift app.
- name: Make DMG
env:
ROOST_DEVELOPER_ID_IDENTITY: ${{ env.CAN_NOTARIZE == 'true' && secrets.ROOST_DEVELOPER_ID_IDENTITY || '' }}
ROOST_DMG_APP_DIR: mac/build/Roost-Iced.app
run: |
set -euo pipefail
ROOST_DMG_BASENAME="Roost-Iced-${GITHUB_REF_NAME#v}" \
./mac/scripts/make-dmg.sh "${GITHUB_REF_NAME#v}"
- name: Notarize + staple (skipped without credentials)
if: env.CAN_NOTARIZE == 'true'
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
run: ./mac/scripts/notarize.sh "mac/build/Roost-Iced-${GITHUB_REF_NAME#v}.dmg"
# A distinct label, because the two DMG rows sit next to each other on
# the Release page and only the label says which one is the experiment.
# The file still downloads as Roost-Iced-<version>.dmg, which is what
# appcast-iced's enclosure URL is built from.
- name: Upload DMG to the Release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release upload "${GITHUB_REF_NAME}" \
"mac/build/Roost-Iced-${GITHUB_REF_NAME#v}.dmg#macOS Iced, experimental (.dmg)" --clobber
# appcast-iced, part 1 of 2. The Swift twin signs with the SwiftPM
# artifact's sign_update; there is no SwiftPM build here, so this uses
# the vendored (pinned + SHA-verified) stage the bundle already needed.
- name: Sign the DMG for Sparkle
env:
ICED_PRIVATE_KEY: ${{ secrets.ROOST_ICED_SPARKLE_ED_PRIVATE_KEY }}
run: |
set -euo pipefail
VER="${GITHUB_REF_NAME#v}"
DMG="mac/build/Roost-Iced-${VER}.dmg"
test -s "${DMG}" || { echo "::error::${DMG} missing — the DMG steps must run first."; exit 1; }
SIGN_UPDATE="third_party/sparkle/out/bin/sign_update"
test -x "${SIGN_UPDATE}" || { echo "::error::${SIGN_UPDATE} missing — the Stage Sparkle step should have produced it."; exit 1; }
W="$(mktemp -d)"
chmod 700 "$W"
trap 'rm -rf "$W"' EXIT
printf '%s' "${ICED_PRIVATE_KEY}" | base64 --decode > "${W}/key"
mkdir -p "${RUNNER_TEMP}/appcast-iced"
"${SIGN_UPDATE}" --ed-key-file "${W}/key" "${DMG}" > "${RUNNER_TEMP}/appcast-iced/sign-iced.txt"
rm -f "${W}/key"
# Not a secret — signature and length both end up in the public feed.
cat "${RUNNER_TEMP}/appcast-iced/sign-iced.txt"
- name: Hand sign-iced.txt to the appcast-iced job
uses: actions/upload-artifact@v7
with:
name: sparkle-sign-iced
path: ${{ runner.temp }}/appcast-iced/sign-iced.txt
if-no-files-found: error
# Artifacts are immutable — without this, re-running just this job
# dies on the existing name instead of recovering.
overwrite: true
# The one irreversible step in the pipeline: everything before it lands on a
# draft nobody can see, everything after it consumes a public release. So the
# assertions belong HERE, where the fix is still "delete the draft and
# re-run" rather than "a broken release is live and apt-charliek already
# republished the previous version".
#
# `needs: [linux, mac, mac-iced]` (and deliberately no `if: always()`) is
# what makes a failed build leave the draft unpublished. mac-iced is in that
# list even though the iced DMG is experimental: publish-release rejects
# unexpected AND missing assets, so the artifact set has to be all-or-
# nothing. An iced build failure therefore blocks the whole release —
# deliberate; draft-until-complete stays absolute.
publish-release:
needs: [linux, mac, mac-iced]
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- name: Assert the artifact set, then publish
env:
GH_TOKEN: ${{ github.token }}
# This job deliberately has no checkout — it only talks to the API.
# But `gh release …` (unlike `gh api <path>`) resolves the repo from
# a git remote, so without this every call fails with "not a git
# repository" and the existence check below reports it as "no
# release exists". v0.0.18 died here on this job's first real run.
GH_REPO: ${{ github.repository }}
run: |
set -euo pipefail
tag="${GITHUB_REF_NAME}"
ver="${tag#v}"
# nfpm's Debian version: it splits at the FIRST hyphen and rejoins
# with `~` (v0.0.18-rc1 -> 0.0.18~rc1), leaving later hyphens alone.
# Same substitution the linux job's smoke step makes; a blanket
# `tr '-' '~'` would mangle v1.2.3-rc-2.
deb_ver="${ver/-/\~}"
# Explicit existence check first: `gh release view` failing inside a
# command substitution wouldn't trip `set -e`, so a missing release
# would otherwise read as an empty isDraft and be misreported below
# as "already published".
if ! gh release view "${tag}" >/dev/null 2>&1; then
echo "::error::no release ${tag} exists — create-release should have left a draft. Nothing to publish."
exit 1
fi
# Read the draft flag into a variable rather than inline in a test.
# A standalone assignment DOES propagate a `gh` failure under
# `set -e`; the same substitution inside `[ ... ]` does not, and a
# transient API error would then read as an empty string. Empty is
# not "true", so the old form treated a failed query as "already
# published", skipped the publish, and let apt dispatch fire against
# a still-draft release — the exact bug this job exists to prevent.
is_draft="$(gh release view "${tag}" --json isDraft -q .isDraft)"
# Idempotent: re-running this job against an already-published
# release is a no-op, not a failure. Publishing twice is harmless,
# but the asset assertions below would be re-litigating a decision
# that can no longer be acted on.
if [ "${is_draft}" = "false" ]; then
echo "::notice::Release ${tag} is already published — nothing to do."
exit 0
fi
# Anything that is neither "true" nor "false" means the query did
# not answer the question. Fail closed.
if [ "${is_draft}" != "true" ]; then
echo "::error::could not determine whether ${tag} is a draft (got '${is_draft}'). Refusing to publish or to assume it is already published."
exit 1
fi
assets="$(gh release view "${tag}" --json assets \
-q '.assets[] | [.name, (.size|tostring)] | @tsv')"
echo "assets currently on the ${tag} draft:"
printf '%s\n' "${assets}"
# Exactly one of each, plausibly sized, named for this tag, and
# NOTHING ELSE. The `#Display Label` suffix on the upload steps only
# sets the Release page's label — the filename is what apt-charliek
# globs and what the appcast's enclosure URL is built from, so that
# is what we check.
#
# The floor is 1 MiB rather than "non-empty": every one of these is
# multi-megabyte, and a truncated upload lands somewhere above zero,
# so `> 0` would wave through exactly the corruption worth catching.
min_bytes=1048576
fail=0
# Space-separated, then split by IFS. No heredoc: its terminator has
# to sit at column 0, which would end this YAML block scalar. None of
# these filenames can contain a space.
#
# GitHub REWRITES asset filenames on upload, replacing anything
# outside [A-Za-z0-9._-] with a dot — so the `~` nfpm puts in a
# prerelease version does not survive: `roost_0.0.18~rc1_arm64.deb`
# is stored as `roost_0.0.18.rc1_arm64.deb`. Verified empirically
# against this repo with a throwaway draft. Without this the checks
# below report `found 0` for every prerelease AND flag the same
# asset as unexpected, so `publish-release` would fail and the draft
# would never flip — for exactly the `-rc` tags used to validate the
# Sparkle round-trip. Stable tags are unaffected, which is why it
# would have hidden until the first prerelease.
expected_names=""
for raw in "roost_${deb_ver}_amd64.deb" \
"roost_${deb_ver}_arm64.deb" \
"Roost-${ver}.dmg" \
"Roost-Iced-${ver}.dmg"; do
expected_names="${expected_names}${expected_names:+ }$(printf '%s' "${raw}" | tr -c 'A-Za-z0-9._-' '.')"
done
echo "expected asset names (after GitHub's filename sanitization): ${expected_names}"
for expected in ${expected_names}; do
sizes="$(printf '%s\n' "${assets}" \
| awk -F'\t' -v e="${expected}" '$1 == e { print $2 }')"
count="$(printf '%s\n' "${sizes}" | grep -c '^[0-9]' || true)"
if [ "${count}" -ne 1 ]; then
echo "::error::expected exactly one asset named ${expected} on ${tag}, found ${count}."
fail=1
continue
fi
if [ "${sizes}" -lt "${min_bytes}" ]; then
echo "::error::asset ${expected} on ${tag} is only ${sizes} bytes (expected at least ${min_bytes}) — it looks truncated."
fail=1
fi
done
# Reject anything else on the release. A reused draft from an earlier
# attempt can still be carrying an asset from a PREVIOUS version —
# apt-charliek globs `roost_*.deb`, so a stale one there is a wrong
# package shipped, and the named checks above would not see it.
unexpected="$(printf '%s\n' "${assets}" | cut -f1 \
| grep -vxF "$(printf '%s' "${expected_names}" | tr ' ' '\n')" || true)"
if [ -n "${unexpected}" ]; then
echo "::error::unexpected assets on ${tag} (a stale upload from an earlier attempt?):"
printf '%s\n' "${unexpected}" | sed 's/^/::error:: /'
fail=1
fi
if [ "${fail}" -ne 0 ]; then
echo "::error::refusing to publish ${tag}. The draft is untouched — inspect it with 'gh release view ${tag}', then re-run the failed jobs or 'gh release delete ${tag}'."
exit 1
fi
gh release edit "${tag}" --draft=false
echo "::notice::Published ${tag} — assets are now publicly downloadable."
# Sparkle appcast, part 2 of 2 (issue #122). Split out of `mac` (where the
# DMG is signed) so it lands strictly after publish-release: the entry it
# writes points at https://github.com/<repo>/releases/download/<tag>/<dmg>,
# which only resolves once the Release leaves draft. A feed published ahead
# of that would point every macOS updater at a 404 until a human noticed.
#
# Consequence worth naming: this runs AFTER the irreversible publish, so a
# failure here leaves a good release with a stale appcast. That's recoverable
# (re-run this job) — the reverse order is not.
appcast:
needs: publish-release
runs-on: ubuntu-latest
timeout-minutes: 15
# Read-only on purpose. The appcast commit is pushed with the release-bot
# App token, not GITHUB_TOKEN: main's ruleset requires ci-success, and only
# the App is in its bypass list (#136).
permissions:
contents: read
steps:
# Commit onto main, not the tagged commit — docs/appcast.xml is served
# from main via Pages. fetch-depth: 0 so the push retry below can rebase
# if main moves under us.
- uses: actions/checkout@v6
with:
ref: main
fetch-depth: 0
persist-credentials: false
- name: Download the Sparkle signature
uses: actions/download-artifact@v7
with: