-
Notifications
You must be signed in to change notification settings - Fork 2
609 lines (573 loc) · 29.6 KB
/
Copy pathrelease.yml
File metadata and controls
609 lines (573 loc) · 29.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
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/**'
- 'esp32/**'
- 'moondeck/build/**'
- '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
# web-installer 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/**'
- 'web-installer/**'
- '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-analyser 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 web-installer/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]' web-installer/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-beta1-${{ runner.os }}-v1
# 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. Raw
# `python` (not `uv run`): this job has no setup-uv (the ESP-IDF docker
# action provides Python) and the script is stdlib-only.
# 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=$(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-beta1, commit b1d13e9f).
# 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-beta1` is the espressif/idf Docker tag pinned to the same
# tagged beta the local pin uses — a fixed, signed pre-release (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/building.md).
esp_idf_version: v6.1-beta1
# 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;
# esp32p4-eth-wifi has ships=False in build_esp32.py so it stays out —
# its C6-slave Kconfig defaults don't survive a plain CI build.)
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 — staged with the prefix.
cp "$B/projectMM.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 — staged under SHARED names so 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"
- uses: actions/upload-artifact@v4
with:
name: esp32-${{ matrix.firmware }}
path: dist/
build-macos:
needs: verify-version
runs-on: macos-14
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
# 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
- name: Build + package macOS arm64
run: uv run moondeck/ci/package_desktop.py
- uses: actions/upload-artifact@v4
with:
name: desktop-macos
path: dist/
build-windows:
needs: verify-version
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
# Same uv prerequisite as build-macos — see the comment there.
- uses: astral-sh/setup-uv@v3
- name: Build + package Windows x64
run: uv run moondeck/ci/package_desktop.py
- uses: actions/upload-artifact@v4
with:
name: desktop-windows
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, build-windows]
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 }}
# 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/
- 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 web-installer/firmwares.json the
# build matrix reads, so manifests and builds can't drift.
for F in $(jq -r '.firmwares[] | select(.ships) | .name' web-installer/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-ota-data.bin
dist/partition-table-*.bin
dist/manifest-*.json
dist/projectMM-*.tar.gz
dist/projectMM-*.zip
# 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-ota-data.bin' \
--pattern 'partition-table-*.bin' \
--pattern 'manifest-*.json' \
--pattern 'projectMM-*.tar.gz' \
--pattern 'projectMM-*.zip' \
|| 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 web-installer/. pages/install/
cp src/ui/install-picker.js pages/install/
# The board-catalog / chip-detection half of the picker — web-installer
# 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-boards.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' web-installer/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 behaviour. 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/projectMM/) — 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 --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"