Skip to content

Commit 0e22201

Browse files
committed
feat(workflow): new manual workflow to update rpi-imager.json
1 parent f270567 commit 0e22201

1 file changed

Lines changed: 324 additions & 0 deletions

File tree

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
#############################################################################
2+
# Update rpi-imager/rpi-imager_falcon_player.json from a published release.
3+
#
4+
# Manual only (workflow_dispatch). Downloads each platform's .img.zip asset
5+
# from a GitHub release, computes the four hash/size fields Raspberry Pi
6+
# Imager needs (compressed zip size+sha256, and the raw .img's extracted
7+
# size+sha256), updates the matching entry in the JSON, and opens a PR --
8+
# it never pushes straight to master.
9+
#
10+
# Only platforms that already have an entry in the JSON are updated (Pi,
11+
# BBB/PB, BB64/PB2 as of this writing). A release asset with no matching
12+
# existing entry (e.g. Pi64, which isn't published to rpi-imager today) is
13+
# skipped with a warning rather than guessing at a new entry's "devices"
14+
# list, icon, etc.
15+
#############################################################################
16+
17+
name: Update RPi Imager JSON
18+
19+
on:
20+
workflow_dispatch:
21+
inputs:
22+
tag:
23+
description: 'Release tag to use (blank = latest published release)'
24+
required: false
25+
default: ''
26+
dry_run:
27+
description: 'Compute and show the diff but do not open a PR'
28+
type: boolean
29+
required: false
30+
default: false
31+
32+
concurrency:
33+
group: update-rpi-imager-json
34+
cancel-in-progress: false
35+
36+
permissions:
37+
contents: write
38+
pull-requests: write
39+
40+
jobs:
41+
update:
42+
runs-on: ubuntu-latest
43+
env:
44+
GH_TOKEN: ${{ github.token }}
45+
steps:
46+
- name: Checkout master
47+
uses: actions/checkout@v5
48+
with:
49+
ref: master
50+
51+
- name: Resolve release
52+
id: release
53+
run: |
54+
if [ -n "${{ inputs.tag }}" ]; then
55+
gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${{ inputs.tag }}" > release.json
56+
else
57+
# /releases/latest is GitHub's own definition of "latest": the
58+
# most recent non-draft, non-prerelease release. Build releases
59+
# are created as drafts (see build-images.yml's "release" job),
60+
# so an unpublished draft is never picked up here by accident.
61+
gh api "repos/${GITHUB_REPOSITORY}/releases/latest" > release.json
62+
fi
63+
64+
TAG=$(jq -r '.tag_name' release.json)
65+
if [ -z "$TAG" ] || [ "$TAG" = "null" ]; then
66+
echo "::error::Could not resolve a release (tag input: '${{ inputs.tag }}')." >&2
67+
exit 1
68+
fi
69+
# FPP release tags carry no 'v' prefix (10.0-beta, 9.5.3); asset
70+
# filenames add it back (FPP-v10.0-beta-Pi.img.zip) -- see
71+
# SD/build-image-pi.sh's OUT_IMG and build-images.yml's version step.
72+
VERSION="$TAG"
73+
PUBLISHED=$(jq -r '.published_at // .created_at' release.json | cut -d'T' -f1)
74+
75+
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
76+
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
77+
echo "release_date=$PUBLISHED" >> "$GITHUB_OUTPUT"
78+
echo "Resolved release: $TAG (published $PUBLISHED)"
79+
80+
- name: Download platform images and compute hashes
81+
id: hashes
82+
run: |
83+
set -euo pipefail
84+
mkdir -p work
85+
# Suffix must match the trailing "-<suffix>.img.zip" of the asset
86+
# filename exactly (a plain substring match would let "Pi" match
87+
# "Pi64" too).
88+
for SUFFIX in Pi Pi64 BB64 BBB; do
89+
NAME=$(jq -r --arg s "$SUFFIX" \
90+
'.assets[] | select(.name | test("-" + $s + "\\.img\\.zip$")) | .name' \
91+
release.json)
92+
URL=$(jq -r --arg s "$SUFFIX" \
93+
'.assets[] | select(.name | test("-" + $s + "\\.img\\.zip$")) | .browser_download_url' \
94+
release.json)
95+
96+
if [ -z "$NAME" ] || [ "$NAME" = "null" ]; then
97+
echo "No .img.zip asset for platform $SUFFIX in this release -- skipping."
98+
continue
99+
fi
100+
101+
echo "=== $SUFFIX: $NAME ==="
102+
ZIP="work/$NAME"
103+
curl -fL --retry 3 -o "$ZIP" "$URL"
104+
105+
ZIP_SIZE=$(stat -c%s "$ZIP")
106+
ZIP_SHA=$(sha256sum "$ZIP" | awk '{print $1}')
107+
108+
IMG_DIR="work/extract-$SUFFIX"
109+
rm -rf "$IMG_DIR"
110+
mkdir -p "$IMG_DIR"
111+
unzip -q "$ZIP" -d "$IMG_DIR"
112+
IMG_FILE=$(find "$IMG_DIR" -maxdepth 1 -name '*.img' | head -n1)
113+
if [ -z "$IMG_FILE" ]; then
114+
echo "::error::No .img found inside $NAME" >&2
115+
exit 1
116+
fi
117+
118+
IMG_SIZE=$(stat -c%s "$IMG_FILE")
119+
IMG_SHA=$(sha256sum "$IMG_FILE" | awk '{print $1}')
120+
121+
# Free the extracted .img right away -- these are multi-GB and
122+
# four platforms in a row will exhaust the runner's disk otherwise.
123+
rm -rf "$IMG_DIR"
124+
rm -f "$ZIP"
125+
126+
{
127+
echo "${SUFFIX}_url=$URL"
128+
echo "${SUFFIX}_image_download_size=$ZIP_SIZE"
129+
echo "${SUFFIX}_image_download_sha256=$ZIP_SHA"
130+
echo "${SUFFIX}_extract_size=$IMG_SIZE"
131+
echo "${SUFFIX}_extract_sha256=$IMG_SHA"
132+
} >> "$GITHUB_OUTPUT"
133+
done
134+
135+
- name: Update JSON
136+
id: update
137+
env:
138+
TAG: ${{ steps.release.outputs.tag }}
139+
VERSION: ${{ steps.release.outputs.version }}
140+
RELEASE_DATE: ${{ steps.release.outputs.release_date }}
141+
Pi_url: ${{ steps.hashes.outputs.Pi_url }}
142+
Pi_image_download_size: ${{ steps.hashes.outputs.Pi_image_download_size }}
143+
Pi_image_download_sha256: ${{ steps.hashes.outputs.Pi_image_download_sha256 }}
144+
Pi_extract_size: ${{ steps.hashes.outputs.Pi_extract_size }}
145+
Pi_extract_sha256: ${{ steps.hashes.outputs.Pi_extract_sha256 }}
146+
Pi64_url: ${{ steps.hashes.outputs.Pi64_url }}
147+
Pi64_image_download_size: ${{ steps.hashes.outputs.Pi64_image_download_size }}
148+
Pi64_image_download_sha256: ${{ steps.hashes.outputs.Pi64_image_download_sha256 }}
149+
Pi64_extract_size: ${{ steps.hashes.outputs.Pi64_extract_size }}
150+
Pi64_extract_sha256: ${{ steps.hashes.outputs.Pi64_extract_sha256 }}
151+
BB64_url: ${{ steps.hashes.outputs.BB64_url }}
152+
BB64_image_download_size: ${{ steps.hashes.outputs.BB64_image_download_size }}
153+
BB64_image_download_sha256: ${{ steps.hashes.outputs.BB64_image_download_sha256 }}
154+
BB64_extract_size: ${{ steps.hashes.outputs.BB64_extract_size }}
155+
BB64_extract_sha256: ${{ steps.hashes.outputs.BB64_extract_sha256 }}
156+
BBB_url: ${{ steps.hashes.outputs.BBB_url }}
157+
BBB_image_download_size: ${{ steps.hashes.outputs.BBB_image_download_size }}
158+
BBB_image_download_sha256: ${{ steps.hashes.outputs.BBB_image_download_sha256 }}
159+
BBB_extract_size: ${{ steps.hashes.outputs.BBB_extract_size }}
160+
BBB_extract_sha256: ${{ steps.hashes.outputs.BBB_extract_sha256 }}
161+
run: |
162+
python3 <<'PY'
163+
import json
164+
import os
165+
import re
166+
167+
path = "rpi-imager/rpi-imager_falcon_player.json"
168+
with open(path) as f:
169+
data = json.load(f)
170+
171+
version = os.environ["VERSION"]
172+
release_date = os.environ["RELEASE_DATE"]
173+
174+
# FPP tags are plain numeric for stable releases (9.5.3, 9.6) and
175+
# carry a letter suffix for anything else (10.0-beta, 10.0-beta2,
176+
# 10.0-rc1) -- see build-images.yml's tag-shape comment. Use that
177+
# to sort entries into (at most) one "stable" slot and one
178+
# "prerelease" slot per platform, so e.g. releasing 10.0-beta2
179+
# replaces 10.0-beta1 in place, and releasing 9.6 replaces 9.5 --
180+
# while a prerelease never touches the stable entry and vice versa.
181+
def channel_of(v):
182+
return "prerelease" if re.search(r"[A-Za-z]", v) else "stable"
183+
184+
# major.minor family, e.g. "10.0-beta2" / "10.0-rc1" / "10.0" -> "10.0",
185+
# "9.5.3" -> "9.5". Used so a stable GA release (10.0) supersedes every
186+
# prerelease of that same family (10.0-beta, 10.0-beta2, 10.0-rc1, ...).
187+
def family_of(v):
188+
m = re.match(r"^(\d+\.\d+)", v)
189+
return m.group(1) if m else v
190+
191+
new_channel = channel_of(version)
192+
new_family = family_of(version)
193+
194+
# Longest suffix first: "Pi64" must be checked before "Pi", since
195+
# "Pi" is a substring of "Pi64" and would otherwise match both.
196+
PLATFORM_SUFFIXES = ("Pi64", "Pi", "BB64", "BBB")
197+
name_re = re.compile(r"^FPP\s+v(\S+)\s+(.*)$")
198+
199+
def entry_platform(o):
200+
for suffix in PLATFORM_SUFFIXES:
201+
if re.search(rf"-{re.escape(suffix)}\.img\.zip$", o["url"]):
202+
return suffix
203+
return None
204+
205+
def entry_channel(o):
206+
m = name_re.match(o.get("name", ""))
207+
return channel_of(m.group(1)) if m else "stable"
208+
209+
added, updated, removed_dupes, superseded, skipped = [], [], [], [], []
210+
211+
for suffix in PLATFORM_SUFFIXES:
212+
url = os.environ.get(f"{suffix}_url", "")
213+
if not url:
214+
continue # no asset for this platform in the release
215+
216+
same_platform = [o for o in data["os_list"] if entry_platform(o) == suffix]
217+
same_slot = [o for o in same_platform if entry_channel(o) == new_channel]
218+
219+
if new_channel == "stable":
220+
# A stable release supersedes every prerelease build of the
221+
# same major.minor family: 10.0 GA removes 10.0-beta,
222+
# 10.0-beta2, 10.0-rc1, etc. for this platform. Prereleases
223+
# of an *unrelated* family (e.g. an 11.0-beta already in
224+
# flight when 10.0 ships) are left alone.
225+
for stale in [o for o in same_platform
226+
if entry_channel(o) == "prerelease"
227+
and family_of(name_re.match(o.get("name", "")).group(1)) == new_family]:
228+
data["os_list"].remove(stale)
229+
superseded.append(stale.get("name", suffix))
230+
231+
if same_slot:
232+
entry = same_slot[0]
233+
for dupe in same_slot[1:]:
234+
# Shouldn't normally happen -- more than one existing
235+
# entry in the same (platform, channel) slot. Collapse
236+
# to the first and drop the rest rather than leaving
237+
# ambiguous duplicates behind.
238+
data["os_list"].remove(dupe)
239+
removed_dupes.append(dupe.get("name", suffix))
240+
updated.append(suffix)
241+
else:
242+
# First release ever seen in this channel for this platform
243+
# (e.g. the very first *-beta build). Clone devices/icon/
244+
# website/init_format from any existing same-platform entry
245+
# so those don't have to be guessed; skip entirely if this
246+
# platform has never been published under any channel.
247+
template = same_platform[0] if same_platform else None
248+
if template is None:
249+
skipped.append(suffix)
250+
continue
251+
entry = dict(template)
252+
entry["devices"] = list(template["devices"])
253+
data["os_list"].append(entry)
254+
added.append(suffix)
255+
256+
m = name_re.match(entry.get("name", ""))
257+
label = m.group(2) if m else suffix
258+
entry["name"] = f"FPP v{version} {label}"
259+
entry["url"] = url
260+
entry["image_download_size"] = int(os.environ[f"{suffix}_image_download_size"])
261+
entry["image_download_sha256"] = os.environ[f"{suffix}_image_download_sha256"]
262+
entry["extract_size"] = int(os.environ[f"{suffix}_extract_size"])
263+
entry["extract_sha256"] = os.environ[f"{suffix}_extract_sha256"]
264+
entry["release_date"] = release_date
265+
266+
with open(path, "w") as f:
267+
f.write(json.dumps(data, indent=4) + "\n")
268+
269+
print("Added new", new_channel, "entry:", ", ".join(added) or "(none)")
270+
print("Updated existing", new_channel, "entry:", ", ".join(updated) or "(none)")
271+
if superseded:
272+
print("Removed superseded prerelease entries:", ", ".join(superseded))
273+
if removed_dupes:
274+
print("Removed duplicate stale entries:", ", ".join(removed_dupes))
275+
if skipped:
276+
print("Skipped (platform never published, no template entry to clone):", ", ".join(skipped))
277+
278+
with open(os.environ["GITHUB_OUTPUT"], "a") as f:
279+
f.write(f"added={','.join(added)}\n")
280+
f.write(f"updated={','.join(updated)}\n")
281+
f.write(f"superseded={','.join(superseded)}\n")
282+
f.write(f"changed={'1' if (added or updated or superseded) else ''}\n")
283+
f.write(f"channel={new_channel}\n")
284+
PY
285+
286+
- name: Show diff
287+
run: git diff -- rpi-imager/rpi-imager_falcon_player.json
288+
289+
- name: Open PR
290+
if: ${{ inputs.dry_run != true && steps.update.outputs.changed != '' }}
291+
env:
292+
TAG: ${{ steps.release.outputs.tag }}
293+
CHANNEL: ${{ steps.update.outputs.channel }}
294+
ADDED: ${{ steps.update.outputs.added }}
295+
UPDATED: ${{ steps.update.outputs.updated }}
296+
SUPERSEDED: ${{ steps.update.outputs.superseded }}
297+
run: |
298+
if git diff --quiet -- rpi-imager/rpi-imager_falcon_player.json; then
299+
echo "No changes -- JSON already up to date with $TAG."
300+
exit 0
301+
fi
302+
303+
BRANCH="update-rpi-imager-json-${TAG}"
304+
git config user.name "github-actions[bot]"
305+
git config user.email "github-actions[bot]@users.noreply.github.com"
306+
git checkout -b "$BRANCH"
307+
git add rpi-imager/rpi-imager_falcon_player.json
308+
git commit -m "chore(rpi-imager): update to release ${TAG}"
309+
git push -f origin "$BRANCH"
310+
311+
BODY="Automated update of \`rpi-imager_falcon_player.json\` to release [${TAG}](https://github.com/${GITHUB_REPOSITORY}/releases/tag/${TAG}) (${CHANNEL} channel)."
312+
[ -n "$ADDED" ] && BODY="${BODY}
313+
- Added new entries: ${ADDED}"
314+
[ -n "$UPDATED" ] && BODY="${BODY}
315+
- Replaced previous ${CHANNEL} entries in place: ${UPDATED}"
316+
[ -n "$SUPERSEDED" ] && BODY="${BODY}
317+
- Removed superseded prerelease entries (same major.minor family): ${SUPERSEDED}"
318+
319+
gh pr create \
320+
--title "chore(rpi-imager): update to release ${TAG}" \
321+
--body "$BODY" \
322+
--base master \
323+
--head "$BRANCH" \
324+
|| echo "PR create failed or already exists for $BRANCH"

0 commit comments

Comments
 (0)