Skip to content

Build Images

Build Images #175

Workflow file for this run

#############################################################################
# Build FPP SD card images for Pi, Pi64, BBB, and BB64.
#
# Runs:
# - workflow_dispatch: manual trigger. Pick the branch/tag to build from the
# "Use workflow from" dropdown. Options: build a subset
# of platforms (for quickly testing this workflow), and
# optionally check "make_release" to create/update a
# release for that ref (name = ref, "v" prefix stripped)
# -- add "overwrite_existing" to replace an existing
# release's assets in place instead of failing.
# - schedule: nightly at 04:00 UTC
# - push (release tag): release build (major version >= 10 only); results are
# attached to a GitHub release named from the tag. Tags
# are numeric with NO 'v' prefix (10.0-beta) -- see the
# on.push.tags filter below and src/fppversion.sh.
#
# Strategy:
# - Pi64 / BB64 build on GitHub's free arm64 runners (native, fast)
# - Pi / BBB build on x86_64 runners via qemu-arm-static (slow, but
# avoids needing self-hosted hardware)
#
# All four build scripts use FPP_SRC_DIR pointing at the checkout, so local
# changes in the tree are picked up without needing to push first.
#############################################################################
name: Build Images
on:
workflow_dispatch:
inputs:
version:
description: 'One-off build version string (ignored if "Create/update a release" is checked; blank = nightly-YYYYMMDD)'
required: false
default: ''
platforms:
description: 'Platforms to build (use a subset to test this workflow quickly)'
type: choice
required: false
default: 'all'
options:
- 'all'
- 'pi'
- 'pi64'
- 'bb64'
- 'bbb'
- 'pi,pi64'
- 'bb64,bbb'
make_release:
description: 'Create/update a release from the branch or tag picked above ("Use workflow from"). Release name always matches that ref (leading "v" stripped).'
type: boolean
required: false
default: false
overwrite_existing:
description: 'If a release already exists for that ref, replace its assets in place instead of failing'
type: boolean
required: false
default: false
schedule:
- cron: '0 4 * * *'
push:
# Auto-build only release tags for major version >= 10. Tags are numeric
# with NO 'v' prefix: src/fppversion.sh runs the tag through `git describe`
# and does MAJOR_VERSION=$(cut -f1 -d.), so a leading 'v' would corrupt every
# displayed version (v10 instead of 10).
#
# `[1-9][0-9]*` matches any tag whose major version has two-or-more digits
# (10.0-beta, 10.4, 11.2, 100.0 ...) and so skips every single-digit major
# (9.6, 9.5.4, 8.x ...). The 9.x and earlier branches are NOT wired for
# automated image creation, so they must stay manual. `!*-master` excludes
# the X.x-master branch-point markers (e.g. 10.x-master); 'nightly' is
# skipped automatically since it does not start with a digit.
tags:
- '[1-9][0-9]*'
- '!*-master'
# A manual re-run on the same ref should cancel a running build to avoid
# wasting a 2-hour qemu job when you realize you tagged the wrong commit.
concurrency:
group: build-images-${{ github.ref }}
cancel-in-progress: true
jobs:
prep:
runs-on: ubuntu-latest
if: github.repository == 'FalconChristmas/fpp' # Only run nightly builds on main repo
outputs:
version: ${{ steps.ver.outputs.version }}
is_release: ${{ steps.ver.outputs.is_release }}
is_nightly: ${{ steps.ver.outputs.is_nightly }}
steps:
- name: Determine version string
id: ver
run: |
# Tag push -> release build. on.push.tags only fires this workflow for
# release-shaped tags, which by FPP convention carry NO 'v' prefix
# (10.0-beta, 9.5.3): src/fppversion.sh feeds the tag straight through
# `git describe`, so the version string IS the tag verbatim -- do not
# strip anything.
if [ "${{ github.event_name }}" = "push" ] && [[ "$GITHUB_REF" == refs/tags/* ]]; then
V="${GITHUB_REF#refs/tags/}"
echo "is_release=true" >> "$GITHUB_OUTPUT"
echo "is_nightly=false" >> "$GITHUB_OUTPUT"
# Manual dispatch, "make_release" checked -> release build from
# whatever branch/tag was picked in the "Use workflow from" dropdown.
# The release name always matches that ref (never the free-typed
# "version" input), same as the tag-push path above; a leading "v"
# is stripped since branches follow "vX.Y" but tags/releases/
# fppversion.sh all use the bare "X.Y" form (see header comment).
elif [ "${{ github.event.inputs.make_release }}" = "true" ]; then
V="${{ github.ref_name }}"
case "$V" in
v[0-9]*) V="${V#v}" ;;
esac
# Guard rail: the ref actually built is whatever's picked in the
# "Use workflow from" branch/tag selector at the top of the Run
# workflow form -- a control separate from the "version" text
# input below, defaults to "master", and is easy to leave
# unchanged. Refuse to silently turn a forgotten default into a
# release named "master"/"dev"/etc; require something that
# actually looks like a release ref (starts with a digit, same
# shape the tag-push trigger requires).
case "$V" in
[0-9]*) : ;;
*)
echo "::error::make_release is checked but the ref actually built ('${{ github.ref_name }}') doesn't look like a release branch/tag (expected e.g. 10.0-beta or v10.0-beta). This is controlled by the 'Use workflow from' picker at the top of the Run workflow form, NOT the 'version' text box -- it defaults to 'master', so it's easy to leave unchanged by mistake. Refusing to create a release named '${{ github.ref_name }}'."
exit 1
;;
esac
echo "is_release=true" >> "$GITHUB_OUTPUT"
echo "is_nightly=false" >> "$GITHUB_OUTPUT"
# Manual dispatch with explicit version -> one-off build
elif [ -n "${{ github.event.inputs.version }}" ]; then
V="${{ github.event.inputs.version }}"
echo "is_release=false" >> "$GITHUB_OUTPUT"
echo "is_nightly=false" >> "$GITHUB_OUTPUT"
# Schedule or manual dispatch without version -> nightly.
# Use fixed "nightly" string so artifact filenames are stable and
# URLs like releases/download/nightly/FPP-vnightly-Pi64.img.zip
# don't change night to night.
else
V="nightly"
echo "is_release=false" >> "$GITHUB_OUTPUT"
echo "is_nightly=true" >> "$GITHUB_OUTPUT"
fi
echo "version=$V" >> "$GITHUB_OUTPUT"
echo "Selected version: $V"
build:
needs: prep
strategy:
fail-fast: false
matrix:
include:
- platform: pi
runner: ubuntu-24.04-arm
script: SD/build-image-pi.sh
extra_args: "--arch armhf"
suffix: Pi
- platform: pi64
runner: ubuntu-24.04-arm
script: SD/build-image-pi.sh
extra_args: "--arch arm64"
suffix: Pi64
- platform: bb64
runner: ubuntu-24.04-arm
script: SD/build-image-bb64.sh
extra_args: ""
suffix: BB64
- platform: bbb
runner: ubuntu-24.04-arm
script: SD/build-image-bbb.sh
extra_args: ""
suffix: BBB
runs-on: ${{ matrix.runner }}
# GHA hard cap is 360 min. armhf-under-qemu builds are the long pole
# (~2-3 hours for Pi, expected similar for BBB).
timeout-minutes: 350
steps:
- name: Skip if platform not requested
id: gate
run: |
REQ="${{ github.event.inputs.platforms }}"
if [ -n "$REQ" ] && [ "$REQ" != "all" ]; then
case ",$REQ," in
*",${{ matrix.platform }},"*) echo "skip=false" >> "$GITHUB_OUTPUT" ;;
*) echo "skip=true" >> "$GITHUB_OUTPUT" ;;
esac
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Checkout
if: steps.gate.outputs.skip == 'false'
uses: actions/checkout@v5
with:
submodules: recursive
# fppversion.sh runs `git describe` inside the chroot against the
# rsynced tree; without full history and tags it fails with
# "fatal: No names found, cannot describe anything."
fetch-depth: 0
fetch-tags: true
# Don't leave the short-lived runner token in .git/config --
# the tree gets baked into the shipped image and the token
# would (a) expire, breaking the user's first Upgrade, and
# (b) ship a credential we should never be publishing.
persist-credentials: false
- name: Resolve release branch to build from
# A release IMAGE must ship /opt/fpp on a real tracking BRANCH, never a
# detached tag. Two runtime paths depend on it:
# * scripts/git_pull updates via `git rebase @{u}` -- an upstream only
# exists on a branch, so a detached-tag checkout fails every user's
# "Check for Updates" with "HEAD does not point to a branch".
# * www/common.php keys version display + upgrade offers off a
# `v<major>.<minor>` branch name; a detached HEAD reads back garbage.
# actions/checkout leaves a tag build in detached HEAD, and the branch
# usually has fixes PAST the tag, so we must check out the branch, not
# the tagged commit.
#
# Mapping (FPP convention, one branch per minor series): tag 10.1.2 ->
# branch v10.1. The 10.0 beta is the exception -- its branch is literally
# v10.0-beta -- so when v<major>.<minor> is absent, fall back to the
# single origin/v* branch that contains the tagged commit. A
# workflow_dispatch run already picks the branch in "Use workflow from",
# so its ref_name is used verbatim.
if: steps.gate.outputs.skip == 'false'
id: relbranch
run: |
set -euo pipefail
# Ensure every origin branch is present -- needed both to resolve here
# and because the rsynced .git is what FPP_Install.sh (--skip-clone)
# checks out inside the image. Unauthenticated fetch is fine (public
# repo; persist-credentials:false scrubbed the token).
git fetch --no-tags --quiet origin '+refs/heads/*:refs/remotes/origin/*' || true
if [ "${{ github.event_name }}" = "push" ] && [[ "${{ github.ref }}" == refs/tags/* ]]; then
TAG="${{ github.ref_name }}"
TAG_SHA="$(git rev-list -n1 "$TAG")"
CONV="v$(printf '%s' "$TAG" | sed -E 's/^([0-9]+\.[0-9]+).*/\1/')"
BRANCH=""
if git show-ref --verify --quiet "refs/remotes/origin/$CONV" \
&& git merge-base --is-ancestor "$TAG_SHA" "origin/$CONV"; then
BRANCH="$CONV"
else
CANDS="$(git branch -r --contains "$TAG_SHA" --list 'origin/v[0-9]*' | sed 's#^[* ]*origin/##' | sort -u)"
N="$(printf '%s\n' "$CANDS" | grep -c . || true)"
if [ "$N" = "1" ]; then
BRANCH="$CANDS"
elif [ "$N" -gt 1 ]; then
echo "::error::Tag '$TAG' is contained in multiple release branches ($(echo $CANDS)); refusing to guess. Expected $CONV."
exit 1
fi
fi
if [ -z "$BRANCH" ]; then
echo "::error::No release branch found for tag '$TAG' (expected origin/$CONV, or exactly one origin/v* branch containing the tag). Push the release branch before tagging so images can track it."
exit 1
fi
else
# workflow_dispatch / schedule: build the branch that was checked out.
BRANCH="${{ github.ref_name }}"
fi
echo "Resolved release branch: $BRANCH"
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
echo "FPPBRANCH=$BRANCH" >> "$GITHUB_ENV"
- name: Maximize disk space
# Default GH runners have ~14 GB free; BBB / Pi builds easily use
# 12-15 GB (base image + work image + artifacts). Strip the pre-
# installed SDKs that nothing in this workflow uses.
if: steps.gate.outputs.skip == 'false'
run: |
sudo rm -rf \
/usr/share/dotnet \
/usr/share/swift \
/usr/local/lib/android \
/opt/ghc \
/usr/local/.ghcup \
/usr/local/share/boost \
/opt/hostedtoolcache/CodeQL \
2>/dev/null || true
docker system prune -af 2>/dev/null || true
df -h /
- name: Install build dependencies
if: steps.gate.outputs.skip == 'false'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
qemu-user-static binfmt-support \
parted dosfstools e2fsprogs \
zerofree squashfs-tools zip xz-utils \
wget ca-certificates rsync
- name: Register qemu-arm binfmt
# qemu-user-static on arm64 Ubuntu doesn't register qemu-arm because
# the host arch is different; on x86_64 it usually registers fine,
# but be defensive.
if: steps.gate.outputs.skip == 'false'
run: |
if [ ! -f /proc/sys/fs/binfmt_misc/qemu-arm ]; then
echo "Registering qemu-arm binfmt manually..."
sudo sh -c 'printf ":qemu-arm:M::\x7fELF\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x28\x00:\xff\xff\xff\xff\xff\xff\xff\x00\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff:/usr/bin/qemu-arm-static:OCF" > /proc/sys/fs/binfmt_misc/register' || true
fi
ls /proc/sys/fs/binfmt_misc/qemu-arm 2>/dev/null || \
echo "WARNING: qemu-arm still not registered; armhf chroot will fail"
- name: Resolve base image cache id
# The BB builders discover the latest rcn-ee image at run time (rcn-ee
# keeps only a rolling window of dated builds), so the base filename --
# not the script -- is what changes week to week. Ask the script which
# image it would download and key the cache on that, so the ~6 of 7
# nightly runs where the base is unchanged get a cache hit instead of
# re-downloading ~1GB. Pi pins its base in-script, so its hash is stable.
id: baseid
if: steps.gate.outputs.skip == 'false'
run: |
case "${{ matrix.platform }}" in
bb64|bbb)
URL="$(bash ./${{ matrix.script }} --print-base-image-url 2>/dev/null || true)"
if [ -n "$URL" ]; then
ID="$(basename "$URL")"
else
echo "WARNING: base-image resolve failed; using script hash" >&2
ID="fallback-${{ hashFiles(matrix.script) }}"
fi
;;
*)
ID="${{ hashFiles(matrix.script) }}"
;;
esac
echo "id=$ID" >> "$GITHUB_OUTPUT"
echo "Base image cache id: $ID"
- name: Cache base image download
if: steps.gate.outputs.skip == 'false'
uses: actions/cache@v5
with:
path: build/*.img.xz
# Keyed on the resolved base-image filename (BB) or script hash (Pi)
# so the cache tracks the actual image, not just the script source.
# No restore-keys: an exact miss means a new base, which should be
# downloaded fresh rather than restoring a stale prior week's image.
key: base-image-${{ matrix.platform }}-${{ steps.baseid.outputs.id }}
- name: Build image
if: steps.gate.outputs.skip == 'false'
# Pass the RESOLVED release branch (see "Resolve release branch" above),
# never github.ref_name: on a tag push ref_name is the tag, and building
# that leaves the image's /opt/fpp detached (broken self-update). Passing
# it as a --branch CLI arg -- not the FPPBRANCH env var -- is also
# required because plain `sudo` strips the environment, which is how CI
# run 30107796268 silently fell back to the script's "master" default
# and shipped every 10.0-beta image on master.
run: |
sudo ./${{ matrix.script }} \
--version "${{ needs.prep.outputs.version }}" \
--os-version "$(date -u +%Y-%m)" \
--branch "${{ steps.relbranch.outputs.branch }}" \
--use-local-src \
${{ matrix.extra_args }}
- name: List output
if: steps.gate.outputs.skip == 'false'
run: ls -la output/
- name: Upload image artifacts
if: steps.gate.outputs.skip == 'false'
uses: actions/upload-artifact@v6
with:
name: FPP-${{ needs.prep.outputs.version }}-${{ matrix.suffix }}
path: |
output/FPP-v*-${{ matrix.suffix }}.img.zip
output/${{ matrix.suffix }}-*.fppos
output/ccache-${{ matrix.suffix }}.tar.gz
if-no-files-found: error
retention-days: 14
release:
# Only run on tag push. Creates a draft release so the maintainer can
# add notes before publishing. Attaches all four platforms' artifacts.
needs: [prep, build]
if: needs.prep.outputs.is_release == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Free disk space
# The four platforms' artifacts total ~11 GB. Strip unused preinstalled
# SDKs so the download + release staging can't exhaust the runner's
# ~14 GB root disk (see the nightly_release job for the failure this
# guards against).
run: |
sudo rm -rf \
/usr/share/dotnet \
/usr/share/swift \
/usr/local/lib/android \
/opt/ghc \
/usr/local/.ghcup \
/usr/local/share/boost \
/opt/hostedtoolcache/CodeQL \
2>/dev/null || true
df -h /
- name: Download all artifacts
uses: actions/download-artifact@v5
with:
path: artifacts
- name: Flatten artifact directory
run: |
mkdir -p release-files
# mv, not cp: copying would keep a second ~11 GB duplicate of every
# artifact on disk and can blow the runner's root volume.
find artifacts -type f \( -name '*.img.zip' -o -name '*.fppos' -o -name 'ccache-*.tar.gz' \) \
-exec mv -v {} release-files/ \;
ls -la release-files/
- name: Create draft release
# Skipped for manual "overwrite_existing" runs: this step always
# creates fresh, so it would fail with "already_exists" whenever the
# release we're meant to be replacing assets on is already there.
if: github.event.inputs.overwrite_existing != 'true'
uses: softprops/action-gh-release@v2
with:
draft: true
name: "FPP ${{ needs.prep.outputs.version }}"
tag_name: ${{ needs.prep.outputs.version }}
files: release-files/*
fail_on_unmatched_files: true
- name: Create or update release (overwrite existing assets)
# Manual-dispatch-only path (overwrite_existing is only ever set via
# workflow_dispatch inputs; a tag push leaves it unset/false). Moves
# the release's tag to the commit that was just built -- needed when
# re-releasing from a branch dropdown pick rather than a fresh tag
# push -- then upserts the release and replaces its assets in place,
# mirroring the nightly_release job's non-destructive pattern.
if: github.event.inputs.overwrite_existing == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
run: |
set -euo pipefail
TAG="${{ needs.prep.outputs.version }}"
TITLE="FPP ${{ needs.prep.outputs.version }}"
if gh api "repos/${{ github.repository }}/git/refs/tags/$TAG" >/dev/null 2>&1; then
echo "Moving '$TAG' tag to ${{ github.sha }}..."
gh api -X PATCH "repos/${{ github.repository }}/git/refs/tags/$TAG" \
-f sha="${{ github.sha }}" -F force=true >/dev/null
else
echo "Creating '$TAG' tag at ${{ github.sha }}..."
gh api -X POST "repos/${{ github.repository }}/git/refs" \
-f ref="refs/tags/$TAG" -f sha="${{ github.sha }}" >/dev/null
fi
if gh release view "$TAG" >/dev/null 2>&1; then
echo "Release '$TAG' already exists; replacing its assets in place."
gh release upload "$TAG" release-files/* --clobber
else
echo "No existing release for '$TAG'; creating a new draft."
gh release create "$TAG" release-files/* \
--draft \
--title "$TITLE" \
--verify-tag
fi
nightly_release:
# Runs on schedule + schedule-like manual dispatch. Publishes / replaces
# the "nightly" pre-release so URLs like
# https://github.com/<owner>/<repo>/releases/download/nightly/<file>
# always point at last night's artifacts. Marked prerelease so GitHub's
# /releases/latest/download/ stays pointed at the last real versioned
# release -- nightly doesn't pollute the stable URL.
needs: [prep, build]
# Run even if SOME build legs failed, as long as prep succeeded and this is
# a nightly. A single platform flaking (e.g. a transient qemu/mold linker
# crash) should not block publishing the platforms that DID build -- the
# failed platform simply keeps its asset from the previous nightly (the
# publish step below is non-destructive). Versioned/tag RELEASES stay
# all-or-nothing: that is the separate `release` job, which keeps the
# default success() gate and only runs when every platform succeeds.
if: ${{ !cancelled() && needs.prep.result == 'success' && needs.prep.outputs.is_nightly == 'true' }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Free disk space
# The four platforms' artifacts total ~11 GB. Downloading them plus
# staging for upload previously exhausted the runner's ~14 GB root disk
# and crashed the job MID-PUBLISH -- which, with the old
# delete-then-create logic below, wiped the nightly release entirely.
# Strip unused preinstalled SDKs for ~20 GB of headroom.
run: |
sudo rm -rf \
/usr/share/dotnet \
/usr/share/swift \
/usr/local/lib/android \
/opt/ghc \
/usr/local/.ghcup \
/usr/local/share/boost \
/opt/hostedtoolcache/CodeQL \
2>/dev/null || true
df -h /
- name: Checkout (for gh CLI to know the repo)
uses: actions/checkout@v5
with:
fetch-depth: 1
- name: Download all artifacts
uses: actions/download-artifact@v5
with:
path: artifacts
- name: Flatten artifact directory
run: |
mkdir -p release-files
# mv, not cp: copying kept a second ~11 GB duplicate of every artifact
# on disk, which is what tipped the runner over into "No space left on
# device" and triggered the wipeout.
find artifacts -type f \( -name '*.img.zip' -o -name '*.fppos' -o -name 'ccache-*.tar.gz' \) \
-exec mv -v {} release-files/ \;
ls -la release-files/
- name: Publish / update nightly pre-release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_SHA: ${{ github.sha }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
BUILD_DATE="$(date -u +%Y-%m-%d)"
SHORT="${GH_SHA:0:7}"
# Resilient nightly: this job runs even when some platforms failed to
# build. Publish whatever landed this run; a platform missing from
# this run keeps its asset from the previous nightly (the upload
# below is --clobber, never a delete). Report fresh vs carried-over
# so a partial nightly is never silent.
shopt -s nullglob
staged=(release-files/*)
if [ ${#staged[@]} -eq 0 ]; then
echo "No artifacts were produced by any platform this run;" \
"leaving the existing nightly release untouched." >&2
exit 0
fi
echo "Platform coverage for this nightly:"
for suffix in Pi Pi64 BB64 BBB; do
if [ -e "release-files/FPP-vnightly-$suffix.img.zip" ]; then
echo " [fresh] $suffix"
else
echo " [carried over] $suffix (build failed this run; keeping previous nightly's asset)"
fi
done
NOTES="Automated nightly build (unreleased development snapshot).
**Build date:** $BUILD_DATE UTC
**Commit:** [\`$SHORT\`](../../commit/$GH_SHA)
Static download URLs (always point at the most recent nightly):
- \`releases/download/nightly/FPP-vnightly-Pi.img.zip\`
- \`releases/download/nightly/FPP-vnightly-Pi64.img.zip\`
- \`releases/download/nightly/FPP-vnightly-BB64.img.zip\`
- \`releases/download/nightly/FPP-vnightly-BBB.img.zip\`
> :warning: Nightly builds are unreleased development snapshots.
> Latest production release can be found at https://github.com/FalconChristmas/fpp/releases/latest"
# NON-DESTRUCTIVE replacement. The old code deleted the release+tag
# BEFORE recreating it, so any failure between the two (a disk-full
# runner, a transient API error, a cancelled job) left the repo with
# NO nightly release at all. Instead:
# 1. force-move the 'nightly' tag to this commit,
# 2. create the release only if missing, else edit it in place,
# 3. overwrite assets with --clobber.
# The release is never in a deleted state, so a partial run can only
# ever leave last night's assets -- never zero.
# 1. Move (or create) the nightly tag to this commit.
if gh api "repos/$REPO/git/refs/tags/nightly" >/dev/null 2>&1; then
echo "Moving 'nightly' tag to $SHORT..."
gh api -X PATCH "repos/$REPO/git/refs/tags/nightly" \
-f sha="$GH_SHA" -F force=true >/dev/null
else
echo "Creating 'nightly' tag at $SHORT..."
gh api -X POST "repos/$REPO/git/refs" \
-f ref="refs/tags/nightly" -f sha="$GH_SHA" >/dev/null
fi
# 2. Create the release if it doesn't exist, else refresh its notes.
if gh release view nightly >/dev/null 2>&1; then
echo "Updating existing 'nightly' release..."
gh release edit nightly \
--prerelease \
--title "FPP Nightly ($BUILD_DATE)" \
--notes "$NOTES"
else
echo "Creating 'nightly' pre-release..."
gh release create nightly \
--prerelease \
--verify-tag \
--title "FPP Nightly ($BUILD_DATE)" \
--notes "$NOTES"
fi
# 3. Upload/overwrite assets. Filenames are deterministic (fixed
# "nightly" version + fixed platform suffixes) so there are no orphans
# to prune; --clobber replaces each asset in place.
echo "Uploading assets..."
gh release upload nightly release-files/* --clobber