-
Notifications
You must be signed in to change notification settings - Fork 6
995 lines (948 loc) · 49.8 KB
/
Copy pathrelease.yml
File metadata and controls
995 lines (948 loc) · 49.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
name: Release
# Tag-triggered release pipeline. Builds 4 ESP32 firmwares + 2 desktop binaries
# in parallel, generates 4 ESP Web Tools manifests, attaches everything to the
# GitHub release, and deploys the installer page to GitHub Pages.
#
# Two triggers produce a release:
# - push to main (src/esp32/CMakeLists.txt changed) → tags/publishes as `latest`
# (a moving prerelease, always the newest build from main)
# - push of a vX.Y.Z tag → stable/RC release
#
# Manual replay: workflow_dispatch with an existing tag, useful for retries
# after a transient runner failure (CI is the only producer of release assets;
# never upload by hand).
on:
push:
tags:
- 'v*'
branches:
- main
paths:
- 'src/**'
# The desktop jobs compile the whole tree, tests included (CMakeLists adds test/
# unconditionally), so MSVC and Apple clang see the test suite too — and both have
# rejected code here that the local clang build accepts. Without this line a test-only
# fix cannot trigger the job that flagged it, which left main red twice: the C4146 and
# C4701 fixes each shipped unverified because they touched only test/.
- 'test/**'
- 'esp32/**'
- 'moondeck/build/**'
# package_desktop.py builds and packages the three desktop jobs; without this line a
# packaging fix cannot trigger the jobs that run it (the NSIS-escape fix needed a
# manual dispatch because only moondeck/ci changed).
- 'moondeck/ci/**'
- 'CMakeLists.txt'
- 'library.json'
- '.github/workflows/release.yml'
# The web installer + docs site are served from Pages by the deploy-pages job
# below; a change to them must trigger a deploy or it never reaches the live site
# (the eth-only-provisioning fix shipped a commit that didn't auto-deploy because
# mooninstaller was missing here). src/ui/install-picker*.js is already covered by src/**.
# docs/** covers every page rendered into the Pages root by MkDocs; mkdocs.yml is
# the site config (nav/theme) — a nav change with no doc change must still redeploy.
- 'docs/**'
- 'mooninstaller/**'
- 'mkdocs.yml'
workflow_dispatch:
inputs:
tag:
description: 'Existing tag to (re)release, e.g. v1.0.0'
required: true
# Run one release at a time per tag — prevents two concurrent runs from
# racing on the same release page or Pages deployment. Branch pushes get
# their own concurrency group so a tag and a branch can build in parallel.
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
# Minimal default permissions for every job (read-only). The release job
# overrides this locally with the write/id-token scopes it needs to publish
# the GitHub release + deploy Pages. Verify and build jobs inherit read-only,
# which is all they need (checkout + artifact upload via OIDC).
permissions:
contents: read
jobs:
verify-version:
# Runs on every trigger so downstream `needs:` are always satisfied.
# Version check only applies to real version tags — skip for main push
# (latest) and workflow_dispatch with a non-version tag.
runs-on: ubuntu-latest
steps:
# persist-credentials: false on every checkout that doesn't push — keeps the
# GITHUB_TOKEN out of .git/config where later steps (incl. third-party Docker
# actions) could read it. The `release` job is the one exception: its
# "Re-create latest" step does `git push -f`, so it keeps the credential.
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: astral-sh/setup-uv@v3
- name: Verify tag matches library.json version
if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-') || (github.event_name == 'workflow_dispatch' && startsWith(inputs.tag, 'v'))
# Pass the tag via --tag arg, not via an env var named GITHUB_REF_NAME.
# The built-in GITHUB_REF_NAME is "main" on a push-to-main and would
# shadow a step-level env override. Explicit CLI arg sidesteps the
# collision. Plumbed via env to avoid static-analyzer template-injection
# warning on the direct ${{ }} expansion inside a shell string.
env:
TAG: ${{ inputs.tag || github.ref_name }}
run: uv run moondeck/ci/verify_version.py --tag "$TAG"
# The shipping firmware list, read from the generated mooninstaller/firmwares.json
# (projected from build_esp32.py's FIRMWARES dict, drift-guarded by
# check_firmwares.py). Emitted as a JSON array so build-esp32's matrix can
# fromJSON() it — GitHub matrices can't read a file at parse time, so a job
# output is the standard bridge. This is the ONLY firmware list in CI now.
firmwares:
needs: verify-version
runs-on: ubuntu-latest
outputs:
list: ${{ steps.gen.outputs.list }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- id: gen
run: |
set -euo pipefail
echo "list=$(jq -c '[.firmwares[] | select(.ships) | .name]' mooninstaller/firmwares.json)" >> "$GITHUB_OUTPUT"
build-esp32:
needs: [verify-version, firmwares]
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
firmware: ${{ fromJSON(needs.firmwares.outputs.list) }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
# Full history: compute_version.py counts commits since the last v* tag
# for the `latest` build's `-dev.<N>` suffix. A shallow clone (the default)
# has no tags / partial history and would yield a wrong count.
fetch-depth: 0
- name: Cache ESP-IDF tooling
uses: actions/cache@v4
with:
path: |
~/.espressif
~/esp/esp-idf
# Bump the suffix when changing the IDF version to invalidate.
key: esp-idf-v6.1-${{ runner.os }}-v1
# The staging step's MoonBase block runs `uv run python` on the RUNNER (the ESP-IDF
# docker action only provides Python inside its container), so uv must exist there.
# First main run without it: `uv: command not found`, exit 127, three 4 MB variants.
- uses: astral-sh/setup-uv@v3
# The release-channel tag burned into the binary as MM_RELEASE (shown by
# SystemModule alongside the semver). Same resolution as the release
# job's "Resolve effective tag" step — kept here because this build job
# runs before that one. workflow_dispatch tag wins; main push → latest;
# a vX.Y.Z tag push → that tag.
- name: Resolve release tag
id: tag
env:
INPUT_TAG: ${{ inputs.tag }}
REF_NAME: ${{ github.ref_name }}
IS_MAIN: ${{ github.ref == 'refs/heads/main' }}
run: |
set -euo pipefail
if [ -n "$INPUT_TAG" ]; then echo "tag=$INPUT_TAG" >> "$GITHUB_OUTPUT"
elif [ "$IS_MAIN" = "true" ]; then echo "tag=latest" >> "$GITHUB_OUTPUT"
else echo "tag=$REF_NAME" >> "$GITHUB_OUTPUT"; fi
# The semver burned into the binary + stamped on the assets/manifest. A
# `latest` build gets `<core>-dev.<N>` (N = commits since the last v* tag)
# so successive latest builds are orderable; a stable tag gets the core.
# Computed once here and reused by build + staging so all three agree.
- name: Compute version
id: ver
# The channel (latest vs stable) and the -rc handling both live in
# compute_version.py — pass only the tag, the helper derives the rest, so
# this step and the release job's identical step can't disagree.
# Tag passed via env (not inline ${{ }}) so it reaches the script as a
# plain shell variable, never spliced into the command text — no shell
# injection from a crafted tag/ref.
env:
TAG: ${{ steps.tag.outputs.tag }}
run: |
set -euo pipefail
V=$(uv run python moondeck/build/compute_version.py --tag "$TAG")
echo "version=$V" >> "$GITHUB_OUTPUT"
- name: Build firmware
uses: espressif/esp-idf-ci-action@v1
with:
# Match the local development IDF (v6.1-rc1, commit 44f0c59f).
# The project uses v6-era APIs — `esp_eth_phy_new_generic`, the new
# mDNS component manager, `int clock_gpio` in the EMAC config (v5.x
# had a strongly-typed enum). v5.4 fails to compile platform_esp32.cpp.
# `v6.1-rc1` is the espressif/idf Docker tag pinned to the same
# tagged pre-release the local pin uses — fixed and signed (not the
# rolling `release-v6.1` branch tag), so CI and local build the exact
# same tree. This tracks the v6.1 line toward GA; re-pin to the `v6.1`
# tag once it ships (a deliberate re-test pass, see docs/how-to/building.md).
esp_idf_version: v6.1
# The IDF target follows the firmware-key prefix: esp32s31* → esp32s31
# (checked BEFORE esp32s3 — esp32s31 also startsWith 'esp32s3'),
# esp32s3* → esp32s3, esp32p4* → esp32p4 (the only target that pulls
# the ip101 PHY + esp_hosted, both manifest-gated on target == esp32p4),
# everything
# else → esp32. (The matrix is the `ships` subset of firmwares.json.)
target: ${{ startsWith(matrix.firmware, 'esp32s31') && 'esp32s31' || startsWith(matrix.firmware, 'esp32s3') && 'esp32s3' || startsWith(matrix.firmware, 'esp32p4') && 'esp32p4' || 'esp32' }}
path: 'esp32'
# We run our own builder (not the action's default `idf.py build`)
# so the sdkconfig fragments and EXCLUDE_COMPONENTS go through the
# same code path as local builds. --release burns the channel tag in.
command: python ../moondeck/build/build_esp32.py --firmware ${{ matrix.firmware }} --release "${{ steps.tag.outputs.tag }}" --version "${{ steps.ver.outputs.version }}"
- name: Stage release artifacts
run: |
set -euo pipefail
mkdir -p dist
V="${{ steps.ver.outputs.version }}" # computed once above; matches the binary's MM_VERSION
# Per-firmware build dir under build/esp32-<firmware>/ (plan-19.1).
# build_esp32.py points idf.py at this dir via -B, so the build
# tree lives outside esp32/ and multiple firmwares can coexist:
# CI uses one per matrix job, dev machines use as many as built.
B=build/esp32-${{ matrix.firmware }}
PREFIX="firmware-${{ matrix.firmware }}-v$V"
# App + bootloader are unique per firmware, so staged with the prefix.
cp "$B/MoonLight.bin" "dist/${PREFIX}.bin"
cp "$B/bootloader/bootloader.bin" "dist/${PREFIX}-bootloader.bin"
# ota-data is byte-identical for ALL firmwares, and partition-table is
# identical within a flash-size group, so both are staged under SHARED names and the
# release uploads each once instead of one-per-firmware. Every matrix job
# writes the same shared filename(s); the flatten step keeps one copy
# (byte-identical, so last-writer-wins is correct). The generated
# manifests point at these same names (generate_manifest.py).
SIZE=$(jq -r .flash_settings.flash_size "$B/flasher_args.json" | tr 'A-Z' 'a-z')
cp "$B/partition_table/partition-table.bin" "dist/partition-table-$SIZE.bin"
cp "$B/ota_data_initial.bin" "dist/shared-ota-data.bin"
# Per-firmware flasher_args.json: the release job feeds it to
# generate_manifest.py so offsets come from the real build.
cp "$B/flasher_args.json" "dist/flasher-${{ matrix.firmware }}.json"
# MoonBase, the second boot image on the 4 MB tables (built alongside those firmwares
# by build_esp32.py): chip-shared, so staged under a shared name like the partition
# table. The slot-0 otadata is what makes a fresh install boot the APP with MoonBase
# standing by: blank otadata would boot MoonBase. Their manifests reference both
# (generate_manifest.py), and install-picker.js excludes them from OTA offers.
for MB in build/moonbase-*/MoonLight-moonbase.bin; do
[ -f "$MB" ] || continue
CHIP=$(basename "$(dirname "$MB")"); CHIP=${CHIP#moonbase-}
cp "$MB" "dist/shared-moonbase-$CHIP.bin"
uv run python -c "import sys; sys.path.insert(0,'moondeck/build'); \
import build_esp32, pathlib; \
pathlib.Path('dist/shared-ota-data-slot0.bin').write_bytes(build_esp32.otadata_slot0_bytes())"
done
- uses: actions/upload-artifact@v4
with:
name: esp32-${{ matrix.firmware }}
path: dist/
build-macos-arm64:
needs: verify-version
runs-on: macos-14
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
# Full history: compute_version.py counts commits since the last v* tag.
fetch-depth: 0
# CMakeLists.txt calls find_program(UV_EXECUTABLE … REQUIRED) so the
# build-host Python (gzip / build_info.h) is reached through uv. The
# runners don't ship uv by default — install it before package_desktop.
- uses: astral-sh/setup-uv@v3
# Same tag + version resolution as build-esp32 (see the comments there): the computed
# semver must reach the DESKTOP binary too, or a desktop build reports library.json's bare
# core and the update badge can never see a `latest` (-dev.N) build as newer. shell: bash
# keeps the three jobs' copies of this block identical; without it the Windows copy would
# run under PowerShell and fail on the bash syntax.
- name: Resolve release tag
id: tag
shell: bash
env:
INPUT_TAG: ${{ inputs.tag }}
REF_NAME: ${{ github.ref_name }}
IS_MAIN: ${{ github.ref == 'refs/heads/main' }}
run: |
set -euo pipefail
if [ -n "$INPUT_TAG" ]; then echo "tag=$INPUT_TAG" >> "$GITHUB_OUTPUT"
elif [ "$IS_MAIN" = "true" ]; then echo "tag=latest" >> "$GITHUB_OUTPUT"
else echo "tag=$REF_NAME" >> "$GITHUB_OUTPUT"; fi
- name: Compute version
id: ver
shell: bash
env:
TAG: ${{ steps.tag.outputs.tag }}
run: |
set -euo pipefail
V=$(uv run python moondeck/build/compute_version.py --tag "$TAG")
echo "version=$V" >> "$GITHUB_OUTPUT"
# ccache: the macOS runner compiles the whole desktop tree from cold every run (the ESP32
# jobs get a prebuilt IDF container; this one gets nothing), which is why it was the slowest
# job in the workflow at ~10 min. CMake picks the launcher up from the environment, so the
# build script stays unchanged. The key rotates per run and restores from the newest
# matching prefix, the standard save-always cache shape for a compiler cache.
- name: Install ccache
run: brew install ccache
- name: Restore ccache
uses: actions/cache@v4
with:
path: ~/Library/Caches/ccache
key: ccache-macos-14-${{ github.sha }}
restore-keys: |
ccache-macos-14-
- name: Build + package macOS arm64
env:
CMAKE_CXX_COMPILER_LAUNCHER: ccache
CMAKE_C_COMPILER_LAUNCHER: ccache
run: uv run moondeck/ci/package_desktop.py --version "${{ steps.ver.outputs.version }}"
- name: ccache stats
if: always()
run: ccache --show-stats
- uses: actions/upload-artifact@v4
with:
name: desktop-macos
path: dist/
build-windows-x64:
needs: verify-version
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
fetch-depth: 0
# Same uv prerequisite as build-macos-arm64, see the comment there.
- uses: astral-sh/setup-uv@v3
# Same tag + version resolution as build-esp32 (see the comments there): the computed
# semver must reach the DESKTOP binary too, or a desktop build reports library.json's bare
# core and the update badge can never see a `latest` (-dev.N) build as newer. shell: bash
# keeps the three jobs' copies of this block identical; without it the Windows copy would
# run under PowerShell and fail on the bash syntax.
- name: Resolve release tag
id: tag
shell: bash
env:
INPUT_TAG: ${{ inputs.tag }}
REF_NAME: ${{ github.ref_name }}
IS_MAIN: ${{ github.ref == 'refs/heads/main' }}
run: |
set -euo pipefail
if [ -n "$INPUT_TAG" ]; then echo "tag=$INPUT_TAG" >> "$GITHUB_OUTPUT"
elif [ "$IS_MAIN" = "true" ]; then echo "tag=latest" >> "$GITHUB_OUTPUT"
else echo "tag=$REF_NAME" >> "$GITHUB_OUTPUT"; fi
- name: Compute version
id: ver
shell: bash
env:
TAG: ${{ steps.tag.outputs.tag }}
run: |
set -euo pipefail
V=$(uv run python moondeck/build/compute_version.py --tag "$TAG")
echo "version=$V" >> "$GITHUB_OUTPUT"
# The runner image does NOT ship NSIS (the first release run after the installer merged
# proved it: "no makensis on this CI runner"). choco installs it into
# Program Files (x86)\NSIS, one of the two locations find_makensis already searches.
#
# RETRIED AND VERIFIED, because `choco install` exits 0 when it installed nothing. A 503 from
# the Chocolatey CDN (run 34470353617) left the step green and failed the build two steps
# later with "no makensis on this CI runner", which names our script rather than the outage.
# So: three attempts, then assert the binary exists and fail here if it does not.
- name: Install NSIS (the Windows installer compiler)
shell: pwsh
run: |
# Both install locations, and the same two find_makensis searches. Braces are required
# around the x86 name: "$env:ProgramFiles(x86)" parses as $env:ProgramFiles followed by
# a literal "(x86)", which is a path that never exists.
$candidates = @(
"${env:ProgramFiles(x86)}\NSIS\makensis.exe",
"$env:ProgramFiles\NSIS\makensis.exe"
)
function Find-Nsis { $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1 }
foreach ($attempt in 1..3) {
if (Find-Nsis) { break }
Write-Host "choco install nsis (attempt $attempt of 3)"
choco install nsis --no-progress -y
if (Find-Nsis) { break }
if ($attempt -lt 3) { Start-Sleep -Seconds 15 }
}
$found = Find-Nsis
if (-not $found) {
throw "NSIS did not install after 3 attempts. Chocolatey exits 0 even when it installs nothing, so look above for a CDN error (a 503 caused run 34470353617)."
}
Write-Host "NSIS present: $found"
- name: Build + package Windows x64
run: uv run moondeck/ci/package_desktop.py --version "${{ steps.ver.outputs.version }}"
- uses: actions/upload-artifact@v4
with:
name: desktop-windows
path: dist/
build-linux-x64:
needs: verify-version
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
fetch-depth: 0
# Same uv prerequisite as build-macos-arm64, see the comment there.
- uses: astral-sh/setup-uv@v3
# libcurl is the ONE outbound HTTPS client, and CMake treats it as optional so a
# contributor's machine still builds without it. That optionality is wrong for a PUBLISHED
# artifact: the ubuntu runners carry no libcurl dev package, so every released Linux binary
# until now shipped with the feature compiled out. `|| true` because a transient apt failure
# should not abort a release on its own; CMake's own check is what decides, and it fails the
# build loudly when the library really is absent.
- name: Install libcurl (MoonCloud, only used once consent is given in the app)
run: sudo apt-get update && sudo apt-get install -y libcurl4-openssl-dev || true
# Builds with the runner's default GCC (13 on ubuntu-24.04). Its four false-positive
# warnings are handled in CMakeLists.txt, which demotes them to non-fatal on GCC below
# 16 rather than pinning a compiler here: a from-source build on Debian or Raspberry Pi
# OS meets the same GCC and deserves the same fix.
# Same tag + version resolution as build-esp32 (see the comments there): the computed
# semver must reach the DESKTOP binary too, or a desktop build reports library.json's bare
# core and the update badge can never see a `latest` (-dev.N) build as newer. shell: bash
# keeps the three jobs' copies of this block identical; without it the Windows copy would
# run under PowerShell and fail on the bash syntax.
- name: Resolve release tag
id: tag
shell: bash
env:
INPUT_TAG: ${{ inputs.tag }}
REF_NAME: ${{ github.ref_name }}
IS_MAIN: ${{ github.ref == 'refs/heads/main' }}
run: |
set -euo pipefail
if [ -n "$INPUT_TAG" ]; then echo "tag=$INPUT_TAG" >> "$GITHUB_OUTPUT"
elif [ "$IS_MAIN" = "true" ]; then echo "tag=latest" >> "$GITHUB_OUTPUT"
else echo "tag=$REF_NAME" >> "$GITHUB_OUTPUT"; fi
- name: Compute version
id: ver
shell: bash
env:
TAG: ${{ steps.tag.outputs.tag }}
run: |
set -euo pipefail
V=$(uv run python moondeck/build/compute_version.py --tag "$TAG")
echo "version=$V" >> "$GITHUB_OUTPUT"
- name: Build + package Linux x64
run: uv run moondeck/ci/package_desktop.py --version "${{ steps.ver.outputs.version }}"
- uses: actions/upload-artifact@v4
with:
name: desktop-linux
path: dist/
# The arm64 twin of build-linux-x64: Raspberry Pi, NanoPi and every other 64-bit board, which
# otherwise have to compile from source for an hour.
#
# **ubuntu-22.04-arm, not 24.04, and this is load-bearing.** A binary carries the glibc floor of
# the machine that built it, and 22.04 ships glibc 2.35 where 24.04 ships 2.39. Raspberry Pi OS
# Bookworm, the most widely deployed board OS, has 2.36: a 24.04-built package installs there
# cleanly and then dies at startup with "GLIBC_2.38 not found", the same failure the Dockerfile
# records hitting on debian12. Building against the lower floor covers Bookworm (2.36), Debian 13
# trixie (2.41) and Ubuntu 24.04 (2.39) alike, because glibc symbol versioning is backward
# compatible. Its GCC is 12.3 rather than 13, which CMakeLists already covers: it demotes the four
# false-positive warnings for every GCC below 16.
build-linux-arm64:
needs: verify-version
runs-on: ubuntu-22.04-arm
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
fetch-depth: 0
- uses: astral-sh/setup-uv@v3
# libcurl is the ONE outbound HTTPS client, and CMake treats it as optional so a
# contributor's machine still builds without it. That optionality is wrong for a PUBLISHED
# artifact: the ubuntu runners carry no libcurl dev package, so every released Linux binary
# until now shipped with the feature compiled out. `|| true` because a transient apt failure
# should not abort a release on its own; CMake's own check is what decides, and it fails the
# build loudly when the library really is absent.
- name: Install libcurl (MoonCloud, only used once consent is given in the app)
run: sudo apt-get update && sudo apt-get install -y libcurl4-openssl-dev || true
# Same tag + version resolution as every other build job; see build-linux-x64 for why.
- name: Resolve release tag
id: tag
shell: bash
env:
INPUT_TAG: ${{ inputs.tag }}
REF_NAME: ${{ github.ref_name }}
IS_MAIN: ${{ github.ref == 'refs/heads/main' }}
run: |
set -euo pipefail
if [ -n "$INPUT_TAG" ]; then echo "tag=$INPUT_TAG" >> "$GITHUB_OUTPUT"
elif [ "$IS_MAIN" = "true" ]; then echo "tag=latest" >> "$GITHUB_OUTPUT"
else echo "tag=$REF_NAME" >> "$GITHUB_OUTPUT"; fi
- name: Compute version
id: ver
shell: bash
env:
TAG: ${{ steps.tag.outputs.tag }}
run: |
set -euo pipefail
V=$(uv run python moondeck/build/compute_version.py --tag "$TAG")
echo "version=$V" >> "$GITHUB_OUTPUT"
- name: Build + package Linux arm64
run: uv run moondeck/ci/package_desktop.py --version "${{ steps.ver.outputs.version }}"
- uses: actions/upload-artifact@v4
with:
name: desktop-linux-arm64
path: dist/
release:
# Publish a release on: version tag push, main branch push, or workflow_dispatch.
# Plain branch push without matching paths is filtered by the top-level `paths:`
# so the build jobs already won't run; this condition is the belt to their braces.
if: startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch'
needs: [build-esp32, build-macos-arm64, build-windows-x64, build-linux-x64, build-linux-arm64]
runs-on: ubuntu-latest
permissions:
contents: write # softprops/action-gh-release needs this
# Expose the resolved tag so the tag-only restage job below can dispatch a
# main-context Pages deploy for it (the github-pages environment trusts only
# the `main` ref, so a tag push can't deploy Pages itself).
outputs:
tag: ${{ steps.tag.outputs.tag }}
# The resolved semver, so the container job can tag its image with the same string the
# binary reports. Computed here already for the manifests; exposed rather than recomputed,
# because two computations are two chances to disagree about what this release is called.
version: ${{ steps.ver.outputs.version }}
# No `environment: github-pages` here: that environment's protection rule
# only allows `main`, so binding asset-publishing to it made every vX.Y.Z
# tag run fail BEFORE any step ran — including the asset upload — leaving
# tagged releases with zero binaries. Pages deploy lives in its own job
# below (gated to main, the only ref the environment trusts).
steps:
# NB: this checkout KEEPS persisted credentials (unlike the other jobs) —
# the "Re-create latest" step below force-pushes the `latest` tag with git,
# which needs the token in .git/config.
- uses: actions/checkout@v4
with:
# Full history: compute_version.py counts commits since the last v* tag
# for the manifest's `-dev.<N>` version (must match the binary's).
fetch-depth: 0
- uses: astral-sh/setup-uv@v3
- uses: actions/download-artifact@v4
with:
path: artifacts
- name: Flatten artifacts into dist/
run: |
set -euo pipefail
mkdir -p dist
find artifacts -type f -exec mv {} dist/ \;
ls -la dist/
# The Open Stage Control surface, so someone who owns a device can download it beside the
# firmware instead of being sent to a path inside the repo. It is a plain session file with
# no build step: copied, not generated.
- name: Stage the control-surface session
run: |
set -euo pipefail
cp docs/reference/examples/open-stage-control.json \
dist/MoonLight-control-surface.json
ls -la dist/MoonLight-control-surface.json
- name: Resolve effective tag
id: tag
env:
INPUT_TAG: ${{ inputs.tag }}
REF_NAME: ${{ github.ref_name }}
IS_MAIN: ${{ github.ref == 'refs/heads/main' }}
run: |
set -euo pipefail
if [ -n "$INPUT_TAG" ]; then
echo "tag=$INPUT_TAG" >> "$GITHUB_OUTPUT"
elif [ "$IS_MAIN" = "true" ]; then
echo "tag=latest" >> "$GITHUB_OUTPUT"
else
echo "tag=$REF_NAME" >> "$GITHUB_OUTPUT"
fi
# Same computation as the build job's "Compute version" — the manifest's
# version must match the binary's MM_VERSION + the asset names. Channel +
# -rc handling live in compute_version.py; pass only the tag (this job has
# setup-uv, so `uv run`).
- name: Compute version
id: ver
# Tag via env (not inline ${{ }}) to keep it out of the command text —
# no shell injection from a crafted tag/ref.
env:
TAG: ${{ steps.tag.outputs.tag }}
run: |
set -euo pipefail
V=$(uv run python moondeck/build/compute_version.py --tag "$TAG")
echo "version=$V" >> "$GITHUB_OUTPUT"
- name: Generate ESP Web Tools manifests (release-asset URLs)
env:
TAG: ${{ steps.tag.outputs.tag }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
V="${{ steps.ver.outputs.version }}" # computed once above; matches binary + asset names
# Absolute GitHub release-asset URLs. Uploaded as release assets;
# read by the on-device OTA picker (device fetches the .bin directly
# — no CORS). The Pages-relative manifests are generated in the
# deploy-pages job, where the web installer (CORS-bound) consumes them.
BASE="https://github.com/${REPO}/releases/download/$TAG"
# The shipping firmware list — the same mooninstaller/firmwares.json the
# build matrix reads, so manifests and builds can't drift.
for F in $(jq -r '.firmwares[] | select(.ships) | .name' mooninstaller/firmwares.json); do
uv run python moondeck/build/generate_manifest.py \
--firmware "$F" \
--version "$V" \
--release-url "$BASE" \
--flasher-args "dist/flasher-$F.json" \
--out "dist/manifest-$F.json"
done
- name: Re-create the `latest` release fresh
# The `latest` tag is a moving prerelease: every main-branch build
# republishes it pointing at the new commit. Two things have to
# happen for the Releases page to show the right state:
#
# 1. The tag must point at the new commit. Force-push it.
# 2. The release's `published_at` must reset to now. This is the
# subtle one: softprops/action-gh-release reuses an existing
# release (PATCH) rather than recreating it, and GitHub keeps
# the original `published_at` on a PATCH of an already-
# published release. Result: assets update to today's build
# but the Releases page still sorts/displays the release at
# last week's date (the date it was first published). The
# action exposes no `published_at` input, and PATCHing it
# directly is ignored by the API for published releases — so
# the only reliable reset is to DELETE the release here and
# let the publish step below create it fresh (new release
# object → `published_at` = now).
#
# Deleting the release does NOT delete the tag (we keep the tag,
# force-pushed above). The window between delete and recreate is
# sub-second within this job; the web installer's release-dropdown
# read from api.github.com tolerates it.
#
# Version tags (vX.Y.Z, vX.Y.Z-rcN) are never touched here — those
# are immutable by contract and their releases are created once.
if: steps.tag.outputs.tag == 'latest'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
# 1. Move the tag to the new commit.
git tag -f latest "$GITHUB_SHA"
git push -f origin "refs/tags/latest"
# 2. Delete the existing `latest` RELEASE (not the tag) so the
# publish step recreates it with a fresh published_at.
# `gh release delete` keeps the tag by default (only removes
# it if --cleanup-tag is passed), so the tag we force-pushed
# above survives. Tolerate "release not found" — first-ever
# latest build, or a prior run that already removed it.
gh release delete latest --repo "$GITHUB_REPOSITORY" --yes \
|| echo " (no existing latest release to delete — first build or already gone)"
- name: Publish GitHub release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.tag.outputs.tag }}
# Release `name` is the computed semver (e.g. "2.1.0-dev.7"). The device-
# hosted UI's dev-channel update check reads it from the CORS-readable
# GitHub API (releases/tags/latest) — the manifest-*.json asset that also
# carries the version is fetched via a release-asset URL that redirects to
# release-assets.githubusercontent.com, which sends no CORS header, so the
# browser blocks that read from the device origin. The API exposes `name`
# cross-origin, so surfacing the version here is what makes the badge work.
name: ${{ steps.ver.outputs.version }}
# latest and vX.Y.Z-rcN tags are prerelease — they sort below stable
# on the Releases page and aren't picked up by tooling that asks for
# "latest release". Stable vX.Y.Z tags publish normally.
prerelease: ${{ steps.tag.outputs.tag == 'latest' || contains(steps.tag.outputs.tag, '-rc') }}
fail_on_unmatched_files: true
# The `files:` block below is a multi-line literal string — every line
# is a glob pattern, the action does NOT strip `#` as comment syntax.
files: |
dist/firmware-*.bin
dist/shared-*.bin
dist/partition-table-*.bin
dist/manifest-*.json
dist/MoonLight-*.tar.gz
dist/MoonLight-*.zip
dist/MoonLight-control-surface.json
dist/MoonLight-*.dmg
dist/MoonLight-*-setup.exe
dist/moonlight_*.deb
# Publish the container image to the GitHub Container Registry, one per release.
#
# It runs AFTER `release` and pulls the .deb from that job's artifacts rather than downloading it
# from the API: the release the image describes is the one that just built, so taking the package
# from the same run is what makes the image and the binary provably the same build. It also means
# the image exists the moment the release does, with no window where `latest` points at the
# previous version.
#
# Multi-arch: one tag serves an amd64 server and an arm64 board, because the release now builds
# a .deb for each. Buildx selects the matching package per platform via TARGETARCH.
publish-container:
if: startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch'
needs: [release]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # push to ghcr.io; GITHUB_TOKEN carries it, no secret to manage
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
# The same .debs the release published, from the same run: one per architecture.
- uses: actions/download-artifact@v4
with:
name: desktop-linux
path: dist
- uses: actions/download-artifact@v4
with:
name: desktop-linux-arm64
path: dist
# The arm64 stage runs `dpkg-deb -x` on an amd64 runner, which needs emulation. Only the
# unpack is emulated: nothing is compiled here, so the cost is seconds.
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Lowercase, because a registry path must be and the org is not: `MoonModules/MoonLight`
# would be rejected where `moonmodules/moonlight` is accepted.
- name: Resolve image name
id: img
env:
REPO: ${{ github.repository }}
run: |
set -euo pipefail
echo "name=ghcr.io/$(echo "$REPO" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
# A build context of just the .deb, so the image installs THIS run's package instead of
# resolving one from the API the way a local `docker build` does. Written here rather than
# kept as a second Dockerfile in the repo: it is four lines, and two Dockerfiles that must
# agree about the runtime is exactly the duplication that drifts.
- name: Write the release Dockerfile
run: |
set -euo pipefail
mkdir -p ctx
# One .deb per architecture, named by the arch BuildKit will ask for. TARGETARCH is
# amd64 or arm64, the same suffix package_desktop.py writes, so the stage below picks
# its own package by name and neither list has to be kept in step with the other.
for a in amd64 arm64; do
deb=$(ls dist/moonlight_*_"$a".deb | head -1)
test -n "$deb"
cp "$deb" "ctx/moonlight_$a.deb"
done
ls -la ctx/
cat > ctx/Dockerfile <<'DOCKERFILE'
# --platform=$TARGETPLATFORM: the unpack must happen on the TARGET's architecture, or
# buildx would run the amd64 stage for both and ship an amd64 binary inside an arm64
# image. ARG TARGETARCH is set by BuildKit, no --build-arg needed.
FROM --platform=$TARGETPLATFORM debian:trixie-slim AS fetch
ARG TARGETARCH
COPY moonlight_$TARGETARCH.deb /tmp/moonlight.deb
# libcurl4t64 is installed because the binary LINKS it (the one outbound HTTPS call), and
# the ldd sweep then collects it plus everything it needs. Naming the libraries by hand
# would be wrong the first time that chain moved.
RUN apt-get update \
&& apt-get install -y --no-install-recommends libcurl4t64 \
&& rm -rf /var/lib/apt/lists/* \
&& dpkg-deb -x /tmp/moonlight.deb /rootfs \
&& mkdir -p /deps \
&& ldd /rootfs/usr/bin/MoonLight | awk '/=> \//{print $3}' | sort -u | grep -vE '/(libc|libm|libstdc\+\+|libgcc_s)\.so' | xargs -I{} cp -L {} /deps/
# debian13, NOT debian12: the amd64 binary is built on ubuntu-24.04 (glibc 2.39) and
# needs glibc >= 2.38, where bookworm ships 2.36 and it dies at startup. The arm64 binary
# is built on ubuntu-22.04-arm and floors at 2.35, so it would also run on a bookworm
# base; one base for both keeps the two images the same runtime. See ../../Dockerfile.
# Pinned by digest, not by tag: `cc-debian13` is mutable, so an unpinned base means two
# builds of the same commit can ship different runtimes. Re-pin deliberately when picking
# up base updates (docker buildx imagetools inspect gcr.io/distroless/cc-debian13:latest).
# This digest is the multi-arch INDEX, not one platform's manifest, so the same pin serves
# amd64 and arm64; buildx resolves the right manifest per platform.
FROM gcr.io/distroless/cc-debian13@sha256:9b615fff20e1a4fad29c2b30562580b212c7dd5e2225236735cca0070ed11c78
COPY --from=fetch /rootfs/usr/bin/MoonLight /usr/bin/MoonLight
# The libraries the loader resolved, collected in the fetch stage. Without them the
# container dies at startup on "libcurl.so.4: cannot open shared object file": this job
# builds the image but never runs it, so that failure passed CI and reached a user.
COPY --from=fetch /deps/ /usr/lib/
ENV XDG_DATA_HOME=/data
VOLUME /data
EXPOSE 8080
ENTRYPOINT ["/usr/bin/MoonLight"]
DOCKERFILE
# Two tags: the exact version, which never moves, and `latest`, which follows this workflow's
# own notion of latest (the rolling prerelease from main, or a tagged release). A user pins
# one or tracks the other.
- uses: docker/build-push-action@v6
with:
context: ctx
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ steps.img.outputs.name }}:${{ needs.release.outputs.version }}
${{ steps.img.outputs.name }}:latest
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.version=${{ needs.release.outputs.version }}
org.opencontainers.image.description=Drive large LED installations and DMX fixtures
org.opencontainers.image.licenses=GPL-3.0
# Deploy the web installer to GitHub Pages. Separate from `release` because
# the `github-pages` environment's protection rule only allows `main` — so
# this job is gated to main and carries the environment, while `release`
# (which must run on tags too) stays free of it. The cumulative-content step
# restages the last 5 stable + 5 prerelease releases on every run, so a tag
# release's binaries reach the installer at the next main deploy.
deploy-pages:
if: github.ref == 'refs/heads/main'
needs: [release]
runs-on: ubuntu-latest
permissions:
contents: read
pages: write # actions/deploy-pages needs this
id-token: write # actions/deploy-pages needs this (OIDC for Pages)
environment:
name: github-pages
url: ${{ steps.deploy-pages.outputs.page_url }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: astral-sh/setup-uv@v3
- uses: actions/download-artifact@v4
with:
path: artifacts
- name: Flatten artifacts into dist/
run: |
set -euo pipefail
mkdir -p dist
find artifacts -type f -exec mv {} dist/ \;
ls -la dist/
- name: Stage cumulative release content
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
# Keep the last 5 stable + 5 prerelease releases on Pages so their
# binaries stay flashable (Web Serial is CORS-bound and GitHub
# release assets don't return CORS headers).
STABLE=$(gh release list --limit 50 --exclude-drafts \
--json tagName,isPrerelease \
| jq -r '.[] | select(.isPrerelease|not) | .tagName' | head -5)
PRE=$(gh release list --limit 50 --exclude-drafts \
--json tagName,isPrerelease \
| jq -r '.[] | select(.isPrerelease) | .tagName' | head -5)
KEEP=$(printf '%s\n%s\n' "$STABLE" "$PRE" | sort -u)
echo "Releases to keep on Pages:"
echo "$KEEP" | sed 's/^/ /'
# For each kept tag, stage its binaries AND its manifests onto Pages. A
# GitHub Pages deploy is a full-tree REPLACE (not cumulative — that wrong
# assumption is what made a tag's manifests vanish on the next deploy), so
# every run must reconstruct ALL served tags, not just the one being built.
#
# Use each release's OWN published manifest, not a freshly generated one:
# the asset-naming scheme has changed across releases (v1.0.0 used
# per-firmware `firmware-<F>-v<ver>-partition-table.bin` / `-ota-data.bin`;
# v2.0.0 uses shared `partition-table-<size>.bin` / `shared-ota-data.bin`),
# so regenerating an old tag's manifest with today's generator points it at
# files that release never published → a fetch-firmware 404. Each release's
# own manifest already names its own files correctly; we just rewrite its
# ABSOLUTE github.com asset URLs (which a browser can't fetch — no CORS) to
# RELATIVE basenames, so the installer fetches them same-origin from the
# Pages dir alongside the binaries we download next to them.
for T in $KEEP; do
mkdir -p "pages/install/releases/$T"
gh release download "$T" \
--dir "pages/install/releases/$T" \
--pattern 'firmware-*.bin' \
--pattern '*-ota-data.bin' \
--pattern 'shared-*.bin' \
--pattern 'partition-table-*.bin' \
--pattern 'manifest-*.json' \
--pattern 'MoonLight-*.tar.gz' \
--pattern 'MoonLight-*.zip' \
--pattern 'MoonLight-*.dmg' \
--pattern 'moonlight_*.deb' \
|| echo " (skip $T — no matching assets)"
# Rewrite each manifest's part paths to relative basenames (strip the
# absolute release-asset URL); jq edits in place via a temp file.
for M in pages/install/releases/$T/manifest-*.json; do
[ -f "$M" ] || continue
jq '.builds[].parts[].path |= (sub("^.*/"; "./"))' "$M" > "$M.tmp" && mv "$M.tmp" "$M"
done
echo "Staged binaries + relative manifests into releases/$T"
done
- name: Stage GitHub Pages site
run: |
set -euo pipefail
# Install page + the shared install-picker module sit at the root.
# Each release's binaries + manifests live under releases/<tag>/.
mkdir -p pages/install
cp -r mooninstaller/. pages/install/
cp src/ui/install-picker.js pages/install/
# The device-catalog / chip-detection half of the picker, mooninstaller
# only (not embedded in firmware), imported by index.html. Must ship to
# Pages alongside install-picker.js or the ES-module import 404s.
cp src/ui/install-picker-devices.js pages/install/
# library.json — install page reads the project version from it.
cp library.json pages/install/
# Board picker images live in docs/assets/deviceModels/ (the project's asset
# home, also a library for boards not yet in the catalog). Stage ONLY the
# images a deviceModels.json entry actually references, under install/assets/,
# so an "image": "assets/deviceModels/<slug>.jpg" resolves same-origin from
# /install/ without shipping the unused library to Pages.
mkdir -p pages/install/assets/deviceModels
# rel is "assets/deviceModels/<slug>.<ext>" (the path served from /install/);
# the source file lives in docs/<rel> (i.e. docs/assets/deviceModels/...).
jq -r '.[].image // empty' mooninstaller/deviceModels.json | while read -r rel; do
src="docs/$rel"
[ -f "$src" ] && cp "$src" "pages/install/$rel" \
|| echo "WARNING: deviceModels.json image not found: $src"
done
ls -la pages/ pages/install/
# gen_api.py runs Doxygen -> moxygen to generate the per-module technical pages
# (moonmodules/{core,light}/moxygen/*.md) from each `.h`'s /// comments at
# MkDocs-build time. moxygen runs via `npx moxygen@2.1.10`, so Node must be
# present; pin it explicitly (rather than lean on the runner image's default) so
# a future image change can't silently drop npx or shift its behavior. Doxygen
# is the one apt binary we add — the justified non-uv dependency (like ESP-IDF's
# Python). With the tools present, a moxygen/doxygen failure now RAISES (gen_api
# GenApiError) and fails this build, rather than shipping a site with no API pages.
- name: Set up Node (for npx moxygen)
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Doxygen (source-generated API pages)
run: |
set -euo pipefail
sudo apt-get update && sudo apt-get install -y doxygen
- name: Build docs site into Pages root
run: |
set -euo pipefail
# Render the docs/ tree (Material for MkDocs) as the Pages ROOT
# (moonmodules.github.io/MoonLight/) — the project's front door.
# Config: mkdocs.yml; deps declared inline in the build script (uv
# provisions them). Build to a temp dir, then copy INTO pages/ so the
# installer staged above under pages/install/ survives (a plain
# --site-dir pages would wipe it — mkdocs cleans its output dir).
# history/ and backlog/ are excluded in mkdocs.yml (internal docs).
uv run moondeck/docs/build_docs.py --strict --site-dir "$RUNNER_TEMP/docs-site"
cp -r "$RUNNER_TEMP/docs-site/." pages/
ls -la pages/
- uses: actions/upload-pages-artifact@v3
with:
path: pages
- id: deploy-pages
uses: actions/deploy-pages@v4
# Stage a TAG release onto Pages. A vX.Y.Z tag push builds + publishes the
# release assets (the `release` job), but it can NOT deploy Pages itself: the
# github-pages environment trusts only the `main` ref, so deploy-pages is gated
# to main and a tag push skips it — leaving the installer's same-origin
# manifests for that version un-staged (a 404 at /install/releases/<tag>/).
# Rather than relax the environment's branch policy (needs repo-admin), this
# job re-invokes the workflow on `main` with `tag=<the tag>`, which runs
# deploy-pages under the allowed `main` ref AND sets CUR_TAG=<the tag> (via the
# `tag` input) so the manifests land in releases/<tag>/. Tag-push only, so it
# never recurses (the dispatched run is a workflow_dispatch, not a tag push).
restage-pages-for-tag:
if: startsWith(github.ref, 'refs/tags/v')
needs: [release]
runs-on: ubuntu-latest
permissions:
actions: write # gh workflow run (dispatch this same workflow)
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Dispatch a main-context Pages deploy for this tag
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.release.outputs.tag }}
run: |
set -euo pipefail
echo "Dispatching release.yml on main to stage Pages for $TAG"
gh workflow run release.yml --ref main -f tag="$TAG"