Skip to content

Update RPi Imager JSON #14

Update RPi Imager JSON

Update RPi Imager JSON #14

#############################################################################
# Update rpi-imager/rpi-imager_falcon_player.json from a published release.
#
# Manual only (workflow_dispatch). Downloads each platform's .img.zip asset
# from a GitHub release, computes the four hash/size fields Raspberry Pi
# Imager needs (compressed zip size+sha256, and the raw .img's extracted
# size+sha256), updates the matching entry in the JSON, and opens a PR --
# it never pushes straight to master.
#
# Only platforms that already have an entry in the JSON are updated (Pi,
# BBB/PB, BB64/PB2 as of this writing). A release asset with no matching
# existing entry (e.g. Pi64, which isn't published to rpi-imager today) is
# skipped with a warning rather than guessing at a new entry's "devices"
# list, icon, etc.
#
# The four platforms' downloads/hashing run as parallel matrix jobs (each
# multi-GB image is fetched+extracted+hashed independently) rather than one
# long sequential loop -- they don't depend on each other. A final job
# gathers the per-platform results and does the JSON update + PR.
#############################################################################
name: Update RPi Imager JSON
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag to use (blank = latest published release)'
required: false
default: ''
dry_run:
description: 'Compute and show the diff but do not open a PR'
type: boolean
required: false
default: false
concurrency:
group: update-rpi-imager-json
cancel-in-progress: false
permissions:
contents: write
pull-requests: write
jobs:
resolve:
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
outputs:
tag: ${{ steps.release.outputs.tag }}
steps:
- name: Resolve release
id: release
run: |
if [ -n "${{ inputs.tag }}" ]; then
gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${{ inputs.tag }}" > release.json
else
# /releases/latest excludes prereleases entirely by GitHub's own
# definition -- that hid 10.0-beta (published, prerelease=true)
# behind the older 9.5 stable release. List all releases instead,
# drop drafts (build releases start as drafts -- see
# build-images.yml's "release" job -- so an unpublished one is
# still never picked up by accident), and take whichever
# published release is newest, prerelease or not.
gh api "repos/${GITHUB_REPOSITORY}/releases" --paginate \
| jq -s 'add | map(select(.draft == false)) | sort_by(.published_at) | last' > release.json
fi
TAG=$(jq -r '.tag_name' release.json)
if [ -z "$TAG" ] || [ "$TAG" = "null" ]; then
echo "::error::Could not resolve a release (tag input: '${{ inputs.tag }}')." >&2
exit 1
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "Resolved release: $TAG"
# NOTE: this repo reuses one release/tag across patch bumps -- the
# "9.5" release carries FPP-v9.5-Pi.img.zip, FPP-v9.5.1-*, ...,
# FPP-v9.5.3-*, uploaded on different dates, all under tag_name
# "9.5". So the per-platform VERSION and release date must come
# from the *matched asset itself*, never from this release object's
# tag_name/published_at -- using those here previously overwrote
# correct "9.5.3" entries back down to "9.5" (PR #2772).
- name: Upload release.json
uses: actions/upload-artifact@v4
with:
name: release-json
path: release.json
retention-days: 1
hashes:
needs: resolve
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# Suffix must match the trailing "-<suffix>.img.zip" of the asset
# filename exactly (a plain substring match would let "Pi" match
# "Pi64" too).
suffix: [Pi, Pi64, BB64, BBB]
steps:
- name: Download release.json
uses: actions/download-artifact@v4
with:
name: release-json
- name: Download image and compute hashes
id: hashes
env:
SUFFIX: ${{ matrix.suffix }}
run: |
set -euo pipefail
mkdir -p work
MATCH=$(jq -c --arg s "$SUFFIX" \
'[.assets[] | select(.name | test("-" + $s + "\\.img\\.zip$"))] | sort_by(.updated_at) | last // empty' \
release.json)
if [ -z "$MATCH" ]; then
echo "No .img.zip asset for platform $SUFFIX in this release -- skipping."
exit 0
fi
NAME=$(jq -r '.name' <<<"$MATCH")
URL=$(jq -r '.browser_download_url' <<<"$MATCH")
# Per-asset, not per-release: this repo re-uploads patch bumps
# (9.5, 9.5.1, ..., 9.5.3) onto the SAME release/tag over time,
# so the release object's own tag_name/published_at are stale
# for anything past the first upload (see resolve job's note).
ASSET_DATE=$(jq -r '(.updated_at // .created_at)' <<<"$MATCH" | cut -d'T' -f1)
# Asset filenames are FPP-v<VERSION>-<SUFFIX>.img.zip (see
# SD/build-image-pi.sh's OUT_IMG); strip the fixed prefix/suffix
# to recover VERSION even though it can differ from the release
# tag_name (FPP-v9.5.3-Pi.img.zip on tag "9.5").
ASSET_VERSION="${NAME#FPP-v}"
ASSET_VERSION="${ASSET_VERSION%-${SUFFIX}.img.zip}"
echo "=== $SUFFIX: $NAME (version $ASSET_VERSION, $ASSET_DATE) ==="
ZIP="work/$NAME"
curl -fL --retry 3 -o "$ZIP" "$URL"
ZIP_SIZE=$(stat -c%s "$ZIP")
ZIP_SHA=$(sha256sum "$ZIP" | awk '{print $1}')
IMG_DIR="work/extract-$SUFFIX"
rm -rf "$IMG_DIR"
mkdir -p "$IMG_DIR"
unzip -q "$ZIP" -d "$IMG_DIR"
IMG_FILE=$(find "$IMG_DIR" -maxdepth 1 -name '*.img' | head -n1)
if [ -z "$IMG_FILE" ]; then
echo "::error::No .img found inside $NAME" >&2
exit 1
fi
IMG_SIZE=$(stat -c%s "$IMG_FILE")
IMG_SHA=$(sha256sum "$IMG_FILE" | awk '{print $1}')
# Free the extracted .img right away -- these are multi-GB and
# each matrix job only has one platform's worth of disk headroom.
rm -rf "$IMG_DIR"
rm -f "$ZIP"
jq -n \
--arg url "$URL" \
--arg version "$ASSET_VERSION" \
--arg release_date "$ASSET_DATE" \
--argjson image_download_size "$ZIP_SIZE" \
--arg image_download_sha256 "$ZIP_SHA" \
--argjson extract_size "$IMG_SIZE" \
--arg extract_sha256 "$IMG_SHA" \
'{url: $url, version: $version, release_date: $release_date,
image_download_size: $image_download_size, image_download_sha256: $image_download_sha256,
extract_size: $extract_size, extract_sha256: $extract_sha256}' \
> "hashes-${SUFFIX}.json"
- name: Upload hashes
uses: actions/upload-artifact@v4
if: hashFiles(format('hashes-{0}.json', matrix.suffix)) != ''
with:
name: hashes-${{ matrix.suffix }}
path: hashes-${{ matrix.suffix }}.json
retention-days: 1
update:
needs: [resolve, hashes]
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.resolve.outputs.tag }}
steps:
- name: Checkout master
uses: actions/checkout@v5
with:
ref: master
- name: Download per-platform hashes
uses: actions/download-artifact@v4
with:
pattern: hashes-*
path: hashes
merge-multiple: false
- name: Update JSON
id: update
run: |
python3 <<'PY'
import glob
import json
import os
import re
path = "rpi-imager/rpi-imager_falcon_player.json"
with open(path) as f:
data = json.load(f)
# FPP tags are plain numeric for stable releases (9.5.3, 9.6) and
# carry a letter suffix for anything else (10.0-beta, 10.0-beta2,
# 10.0-rc1) -- see build-images.yml's tag-shape comment. Use that
# to sort entries into (at most) one "stable" slot and one
# "prerelease" slot per platform, so e.g. releasing 10.0-beta2
# replaces 10.0-beta1 in place, and releasing 9.6 replaces 9.5 --
# while a prerelease never touches the stable entry and vice versa.
def channel_of(v):
return "prerelease" if re.search(r"[A-Za-z]", v) else "stable"
# major.minor family, e.g. "10.0-beta2" / "10.0-rc1" / "10.0" -> "10.0",
# "9.5.3" -> "9.5". Used so a stable GA release (10.0) supersedes every
# prerelease of that same family (10.0-beta, 10.0-beta2, 10.0-rc1, ...).
def family_of(v):
m = re.match(r"^(\d+\.\d+)", v)
return m.group(1) if m else v
# Longest suffix first: "Pi64" must be checked before "Pi", since
# "Pi" is a substring of "Pi64" and would otherwise match both.
PLATFORM_SUFFIXES = ("Pi64", "Pi", "BB64", "BBB")
name_re = re.compile(r"^FPP\s+v(\S+)\s+(.*)$")
# A platform with no existing JSON entry at all is skipped by
# default (see below) rather than guessing its "devices" list --
# guessing that field has bitten this file before (2024's
# "pi4-64bit" experiment was reverted a month later for possibly
# breaking flashing for users). Pi64 is a deliberate, verified
# exception: cross-checked against both the official rpi-imager
# schema example (doc/os-sublist-example.json) and the live
# production os_list, which use "<board>-64bit" tags (no suffix on
# pi1/pi2 entries since those chips are 32-bit only) -- e.g.
# Raspberry Pi OS (64-bit) ships devices: ["pi5-64bit", "pi4-64bit",
# "pi3-64bit"]. Mirrors this file's existing "Pi" entry's
# "-32bit"-suffixed convention exactly.
NEW_PLATFORM_TEMPLATES = {
"Pi64": {
"label": "Pi64",
"description": "Falcon Player for 64-bit capable RPi models",
"devices": ["pi5-64bit", "pi4-64bit", "pi3-64bit"],
},
}
def entry_platform(o):
for suffix in PLATFORM_SUFFIXES:
if re.search(rf"-{re.escape(suffix)}\.img\.zip$", o["url"]):
return suffix
return None
def entry_channel(o):
m = name_re.match(o.get("name", ""))
return channel_of(m.group(1)) if m else "stable"
# Each parallel "hashes" matrix job dropped hashes-<SUFFIX>.json
# (absent if that platform had no asset in this release).
per_platform = {}
for f in glob.glob("hashes/hashes-*/hashes-*.json"):
suffix = re.match(r"hashes-(.+)\.json$", os.path.basename(f)).group(1)
with open(f) as fh:
per_platform[suffix] = json.load(fh)
added, updated, removed_dupes, superseded, skipped = [], [], [], [], []
channels_seen = set()
for suffix in PLATFORM_SUFFIXES:
fields = per_platform.get(suffix)
if not fields:
continue # no asset for this platform in the release
url = fields["url"]
version = fields["version"]
release_date = fields["release_date"]
new_channel = channel_of(version)
new_family = family_of(version)
channels_seen.add(new_channel)
same_platform = [o for o in data["os_list"] if entry_platform(o) == suffix]
same_slot = [o for o in same_platform if entry_channel(o) == new_channel]
if new_channel == "stable":
# A stable release supersedes every prerelease build of the
# same major.minor family: 10.0 GA removes 10.0-beta,
# 10.0-beta2, 10.0-rc1, etc. for this platform. Prereleases
# of an *unrelated* family (e.g. an 11.0-beta already in
# flight when 10.0 ships) are left alone.
for stale in [o for o in same_platform
if entry_channel(o) == "prerelease"
and family_of(name_re.match(o.get("name", "")).group(1)) == new_family]:
data["os_list"].remove(stale)
superseded.append(stale.get("name", suffix))
if same_slot:
entry = same_slot[0]
for dupe in same_slot[1:]:
# Shouldn't normally happen -- more than one existing
# entry in the same (platform, channel) slot. Collapse
# to the first and drop the rest rather than leaving
# ambiguous duplicates behind.
data["os_list"].remove(dupe)
removed_dupes.append(dupe.get("name", suffix))
updated.append(suffix)
elif same_platform:
# First release ever seen in this channel for this platform
# (e.g. the very first *-beta build). Clone devices/icon/
# website/init_format from the existing same-platform entry
# so those don't have to be guessed.
template = same_platform[0]
entry = dict(template)
entry["devices"] = list(template["devices"])
data["os_list"].append(entry)
added.append(suffix)
elif suffix in NEW_PLATFORM_TEMPLATES:
# Platform has never had a JSON entry in any channel, but
# it's on the verified allowlist above -- build a fresh
# entry from a same-repo sibling for the generic fields
# (icon/website/init_format never vary between platforms
# here) plus the verified description/devices.
spec = NEW_PLATFORM_TEMPLATES[suffix]
sibling = data["os_list"][0] if data["os_list"] else {}
entry = {
"name": f"FPP v{version} {spec['label']}",
"description": spec["description"],
"url": url,
"icon": sibling.get("icon", ""),
"init_format": sibling.get("init_format", "none"),
"website": sibling.get("website", "https://www.falconplayer.com/"),
"devices": list(spec["devices"]),
}
data["os_list"].append(entry)
added.append(suffix)
else:
# Genuinely unknown platform with no template and no
# verified device list -- skip rather than guess.
skipped.append(suffix)
continue
m = name_re.match(entry.get("name", ""))
label = m.group(2) if m else suffix
entry["name"] = f"FPP v{version} {label}"
entry["url"] = url
entry["image_download_size"] = int(fields["image_download_size"])
entry["image_download_sha256"] = fields["image_download_sha256"]
entry["extract_size"] = int(fields["extract_size"])
entry["extract_sha256"] = fields["extract_sha256"]
entry["release_date"] = release_date
with open(path, "w") as f:
f.write(json.dumps(data, indent=4) + "\n")
print("Added new entries:", ", ".join(added) or "(none)")
print("Updated existing entries:", ", ".join(updated) or "(none)")
if superseded:
print("Removed superseded prerelease entries:", ", ".join(superseded))
if removed_dupes:
print("Removed duplicate stale entries:", ", ".join(removed_dupes))
if skipped:
print("Skipped (platform never published, no template entry to clone):", ", ".join(skipped))
with open(os.environ["GITHUB_OUTPUT"], "a") as f:
f.write(f"added={','.join(added)}\n")
f.write(f"updated={','.join(updated)}\n")
f.write(f"superseded={','.join(superseded)}\n")
f.write(f"changed={'1' if (added or updated or superseded) else ''}\n")
f.write(f"channel={'+'.join(sorted(channels_seen))}\n")
PY
- name: Show diff
run: git diff -- rpi-imager/rpi-imager_falcon_player.json
- name: Open PR
if: ${{ inputs.dry_run != true && steps.update.outputs.changed != '' }}
env:
CHANNEL: ${{ steps.update.outputs.channel }}
ADDED: ${{ steps.update.outputs.added }}
UPDATED: ${{ steps.update.outputs.updated }}
SUPERSEDED: ${{ steps.update.outputs.superseded }}
run: |
if git diff --quiet -- rpi-imager/rpi-imager_falcon_player.json; then
echo "No changes -- JSON already up to date with $TAG."
exit 0
fi
BRANCH="update-rpi-imager-json-${TAG}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout -b "$BRANCH"
git add rpi-imager/rpi-imager_falcon_player.json
git commit -m "chore(rpi-imager): update to release ${TAG}"
git push -f origin "$BRANCH"
BODY="Automated update of \`rpi-imager_falcon_player.json\` to release [${TAG}](https://github.com/${GITHUB_REPOSITORY}/releases/tag/${TAG}) (${CHANNEL} channel)."
[ -n "$ADDED" ] && BODY="${BODY}
- Added new entries: ${ADDED}"
[ -n "$UPDATED" ] && BODY="${BODY}
- Replaced previous ${CHANNEL} entries in place: ${UPDATED}"
[ -n "$SUPERSEDED" ] && BODY="${BODY}
- Removed superseded prerelease entries (same major.minor family): ${SUPERSEDED}"
gh pr create \
--title "chore(rpi-imager): update to release ${TAG}" \
--body "$BODY" \
--base master \
--head "$BRANCH" \
|| echo "PR create failed or already exists for $BRANCH"