-
Notifications
You must be signed in to change notification settings - Fork 1
600 lines (555 loc) · 34 KB
/
Copy pathrelease.yml
File metadata and controls
600 lines (555 loc) · 34 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
################################################################################
#
# release.yml — tag-triggered release: build, verify, package, attest, publish.
#
# Four jobs:
# build `make` (the shared buildroot-build action) + ci-tests, then
# scripts/mk-release.sh turns output/ into a verified dist/
# (stock archive fetched + verified, release_YYYYMMDD.7z
# assembled and round-tripped under the on-device 7za,
# SHA256SUMS), then scripts/mk-sdcard.sh adds the installer
# card image. Every step is a script you can run locally.
# build-azcopy the optional azcopy download, built as a separate config
# publish attest provenance, create a DRAFT GitHub Release
# dependency-graph submit the SBOM to GitHub's dependency graph
#
# Rationale, measurements and incident history: docs/ci.md. The STOCK_* pins
# below are Renovate-managed (docs/renovate.md).
#
################################################################################
name: Release
on:
push:
tags:
- 'v*'
# Manual dispatch builds ONLY the opt-in full sdcard variant; dispatch it
# against a TAG ref (the publish job is guarded to tag refs regardless).
workflow_dispatch:
inputs:
build_full_sdcard:
description: 'Also build + publish sdcard-full.img.xz (bundles _Console cores). Off by default; the minimal sdcard.img.xz is always built on a tag push.'
type: boolean
default: false
concurrency:
group: release-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
build:
name: Build image + assemble release_YYYYMMDD.7z
runs-on: ubuntu-26.04
# Cold build ~3h20m plus packaging and the sdcard installer relink; the
# runner's hard cap is 360. See docs/ci.md#sdcard-timing-6h-cap.
timeout-minutes: 355
outputs:
release_date: ${{ steps.release.outputs.release_date }}
env:
DEBIAN_FRONTEND: noninteractive
STOCK_RELEASE_URL: >-
https://raw.githubusercontent.com/MiSTer-devel/SD-Installer-Win64_MiSTer/cd80db9c0ab64ba38be95071a090a80c367d63cf/release_20260912.7z.001
https://raw.githubusercontent.com/MiSTer-devel/SD-Installer-Win64_MiSTer/cd80db9c0ab64ba38be95071a090a80c367d63cf/release_20260912.7z.002
STOCK_RELEASE_MD5: 7cec2206e2a1133aa307c541219aa08f
STOCK_RELEASE_SHA256: 35fcbaca57cd2471b1d353f3dd4bae7c7e67256f8c5c8b2d6d5cbcc78269a7ea
STOCK_RELEASE_SIZE: "126546478"
STOCK_UBOOT_SHA256: e2d46cf9fe1ec40ca2c9c7409870249f267e06f70e5736dc6d30b4e21fe62a64
STOCK_UBOOT_SIZE: "515141"
STOCK_UPDATEBOOT_SHA256: 6ff2d50a080e26d7173b61c52083e9cc42ca658db0c5031b4da1c45c74a562f2
STOCK_UPDATEBOOT_SIZE: "407"
# --- Pinned ARM 7za (dynamically linked, glibc) -----------------------
# The exact binary the real on-device Downloader fetches; its upstream
# URL is an intentionally unpinned floating ref (matches what real
# devices fetch) — the MD5 check below is the actual security control.
# See docs/ci.md#stock-payload-sourcing.
STOCK_7ZA_GZ_URL: https://github.com/MiSTer-devel/SD-Installer-Win64_MiSTer/raw/master/7za.gz
STOCK_7ZA_GZ_MD5: ed1ad5185fbede55cd7fd506b3c6c699
STOCK_7ZA_GZ_SIZE: "465600"
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# /MiSTer.version is YYMMDD of the tagged commit (UTC); post-build.sh
# bakes $MISTER_VERSION into the image and mk-release.sh reads it back.
- name: Derive release version (YYMMDD) from the tagged commit
run: |
set -eu
v="${MISTER_VERSION:-$(TZ=UTC git show -s --format=%cd --date=format-local:%y%m%d HEAD)}"
case "$v" in [0-9][0-9][0-9][0-9][0-9][0-9]) ;; *) echo "::error::derived MISTER_VERSION '$v' is not 6 digits YYMMDD" >&2; exit 1 ;; esac
echo "MISTER_VERSION=$v" >> "$GITHUB_ENV"
echo "Release version (YYMMDD) = $v -> /MiSTer.version, release_20${v}.7z, db.json"
- name: Build the image
uses: ./.github/actions/buildroot-build
with:
# p7zip-full packs the 7z; the rest are host tools mk-sdcard.sh needs.
extra-apt-packages: p7zip-full genimage mtools dosfstools xz-utils jq
run-legal-info: "true"
- name: Run parity suite + ABI/SONAME checker
uses: ./.github/actions/verify-image
with:
skip-qemu-system: "true"
- name: Stage, package and verify the release assets (scripts/mk-release.sh)
id: release
run: |
set -eu
scripts/mk-release.sh output dist
echo "release_date=$(cat dist/RELEASE_DATE)" >> "$GITHUB_OUTPUT"
# Reuses the stock archive mk-release.sh already verified by seeding
# mk-sdcard.sh's fetch cache (it re-checks size/MD5/SHA-256 itself).
- name: Build + verify the sdcard installer image
env:
STAGE_DIR: ${{ github.workspace }}/output-sdcard-stage
SDCARD_CORES: ${{ inputs.build_full_sdcard && '1' || '0' }}
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
mkdir -p "$STAGE_DIR/.fetch-cache"
cp -f release-work/stock_release.7z "$STAGE_DIR/.fetch-cache/stock_release.7z"
scripts/mk-sdcard.sh
img=sdcard.img; [ "$SDCARD_CORES" = 1 ] && img=sdcard-full.img
scripts/check-sdcard.sh "output/images/$img"
cp -f "output/images/$img.xz" dist/
ls -la dist/
# docs/uboot-tasks.md U5: the DE10's mainline U-Boot ships nowhere and
# `scripts/ci-tests.sh` (the "Run parity suite" step above) only has
# the offline structural/built-SPL-only subset of check-uboot-parity.sh
# / check-uboot-handoff.sh -- the stock uboot.img does not exist on
# disk until the sdcard step just above fetches and verifies it. This
# is the one place that download is already paid for, so every tagged
# release proves FULL parity (environment + command table + handoff
# tables against the real stock blob) without a second fetch and
# without any PR paying for it.
#
# This step is ALSO the only place the "ships nowhere" half of owner
# decision 1 can actually be enforced in CI: dist/ is created by
# scripts/mk-release.sh, which runs AFTER verify-image, so ci-tests.sh's
# own release-stage assertion can only ever SKIP on a runner. Here dist/
# is fully populated (mk-release.sh's assets plus the sdcard image the
# step above copied in), so the grep has teeth.
- name: Verify U-Boot (DE10) parity against the stock blob (docs/uboot-tasks.md U5)
env:
STOCK_UBOOT_IMG: ${{ github.workspace }}/output-sdcard-stage/mister-payload/linux/uboot.img
run: |
set -eu
shopt -s nullglob # so an unmatched glob counts 0, not 1 literal
uboot_trees=(output/build/uboot-*)
[ "${#uboot_trees[@]}" -eq 1 ] ||
{ echo "::error::expected exactly one output/build/uboot-*, found ${#uboot_trees[@]}"; exit 1; }
uboot_tree=${uboot_trees[0]}
[ -f "$STOCK_UBOOT_IMG" ] ||
{ echo "::error::no stock uboot.img at $STOCK_UBOOT_IMG -- the sdcard step above should have staged it"; exit 1; }
# "ships nowhere", asserted against the real, finished release stage.
echo "--- release stage (dist/) must carry no bootloader artifact ---"
ls -la dist/
stage_hits=$(find dist -maxdepth 1 \( -name 'uboot.img' -o -name '*.sfp' \))
[ -z "$stage_hits" ] ||
{ echo "::error::owner decision 1 says the DE10's mainline U-Boot ships nowhere, but the release stage carries: $(echo "$stage_hits" | tr '\n' ' ')"; exit 1; }
echo "ok: no uboot.img/*.sfp in dist/"
spl_limit=$("$uboot_tree/tools/spl_size_limit")
SPL_SIZE_LIMIT="$spl_limit" scripts/check-uboot-parity.sh \
output/images/u-boot-with-spl.sfp \
"$STOCK_UBOOT_IMG" \
"$uboot_tree/u-boot"
scripts/check-uboot-handoff.sh \
"$uboot_tree/board/terasic/de10-nano/qts" \
output/images/u-boot-with-spl.sfp \
"$STOCK_UBOOT_IMG"
- name: Release overview (job summary)
env:
RELEASE_DATE: ${{ steps.release.outputs.release_date }}
TAG: ${{ github.ref_name }}
run: |
set -u
source scripts/ci-lib.sh
{
echo "## 🚀 Release build summary"
echo ""
echo "| Fact | Value |"
echo "|---|---|"
echo "| Tag | \`${TAG}\` |"
echo "| Internal version (\`/MiSTer.version\`) | \`${RELEASE_DATE}\` |"
echo "| Archive | \`release_${RELEASE_DATE}.7z\` ($(ci_lib_sz "dist/release_${RELEASE_DATE}.7z")) |"
echo "| linux.img | $(ci_lib_sz dist/linux.img) |"
echo "| zImage_dtb / zImage_dtb-rt | $(ci_lib_sz dist/zImage_dtb) / $(ci_lib_sz dist/zImage_dtb-rt) |"
echo "| Assets staged | $(find dist -maxdepth 1 -type f | wc -l) |"
echo ""
echo "**Verified before packaging:** stock archive MD5/SHA-256/size + 7z CRC · \`uboot.img\`/\`updateboot\` byte-identical to stock before *and* after the round trip · our \`.7z\` extracts under the pinned ARM \`7za\` (qemu-arm) · archive member list matches the assembled tree · rootfs passes \`ci-tests.sh\` and the ABI/SONAME checker."
echo ""
echo "**Next:** \`publish\` attests provenance and creates a **DRAFT** release; db.json is published only after a human clicks **Publish** (\`publish-db.yml\` gates on a non-draft release)."
echo ""
echo "<details><summary>SHA256SUMS</summary>"; echo ""; echo '```'; cat dist/SHA256SUMS; echo '```'; echo ""; echo "</details>"
} >> "$GITHUB_STEP_SUMMARY"
- name: Report disk & cache usage
if: always()
run: |
echo "--- disk free ---"; df -h .
echo "--- dl/ ---"; du -sh dl 2>/dev/null || true
echo "--- output ---"; du -sh output 2>/dev/null || true
echo "--- ccache ---"; du -sh "$GITHUB_WORKSPACE/.ccache" 2>/dev/null || true
echo "--- dist/ ---"; ls -la dist/ 2>/dev/null || true
- name: Upload release asset bundle
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release-dist-${{ github.sha }}
path: dist/
if-no-files-found: error
retention-days: 14
build-azcopy:
name: Build azcopy (optional download)
runs-on: ubuntu-26.04
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install build dependencies
run: |
set -eu
sudo -E apt-get update -qq
sudo -E apt-get install -y --no-install-recommends \
build-essential bash bc bison flex gawk \
git patch gzip bzip2 xz-utils cpio unzip rsync \
file wget curl ca-certificates perl \
python3 python3-dev \
libssl-dev libncurses-dev pkg-config texinfo \
qemu-user
# A DISTINCT cache key from the `build` job's br-dl-* on purpose. Sharing
# it would let whichever job wrote first publish its dl/ under that key --
# and this job's dl/ holds only the Buildroot tarball, azcopy and the Go
# bootstrap, so if it won the race it would hand the image build a dl/
# cache missing almost everything. Immutable-once-written caches make that
# a silent slow rebuild, not an error.
- name: Cache dl/ (azcopy job only)
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: dl/
key: br-dl-azcopy-${{ hashFiles('Makefile', 'package/azcopy/azcopy.mk', 'package/azcopy/azcopy.hash') }}
restore-keys: br-dl-azcopy-
# `echo >> .config` + olddefconfig is the standard Buildroot idiom for
# "the committed configuration, plus this one symbol". BR2_PACKAGE_HOST_GO
# and BR2_PACKAGE_CA_CERTIFICATES come along via the package's own selects.
- name: Configure (the DE10 defconfig + azcopy)
run: |
set -eu
make mister_de10nano_defconfig
echo 'BR2_PACKAGE_AZCOPY=y' >> output/.config
make olddefconfig
grep -qx 'BR2_PACKAGE_AZCOPY=y' output/.config
# AZCOPY_GO_ENV is appended after HOST_GO_TARGET_ENV by pkg-golang.mk, so
# this CGO_ENABLED wins over the =1 that config would otherwise give. Set
# on the command line rather than in azcopy.mk because it is a property of
# THIS artifact, not of the package: an in-image azcopy should keep cgo and
# glibc's NSS resolver.
- name: Build azcopy (static)
run: make azcopy AZCOPY_GO_ENV=CGO_ENABLED=0
# Fail loudly here rather than ship a binary nobody ran. `file` proves the
# static link the whole artifact rationale rests on, and qemu-user proves
# it actually executes on ARM -- the check that would have caught the
# keyctl SIGILL (package/azcopy/0002-*) had it been in a startup path.
- name: Verify the artifact
run: |
set -eu
bin=output/target/usr/bin/azcopy
ver=$(sed -n 's/^AZCOPY_VERSION[[:space:]]*=[[:space:]]*//p' package/azcopy/azcopy.mk | tr -d '[:space:]')
file "$bin"
file "$bin" | grep -q 'statically linked' || {
echo "::error::azcopy is not statically linked -- CGO_ENABLED=0 did not take effect" >&2
exit 1
}
got=$(qemu-arm "$bin" --version)
echo "$got"
[ "$got" = "azcopy version $ver" ] || {
echo "::error::built azcopy reports '$got', expected 'azcopy version $ver'" >&2
exit 1
}
- name: Stage azcopy-armv7 asset
run: |
set -eu
ver=$(sed -n 's/^AZCOPY_VERSION[[:space:]]*=[[:space:]]*//p' package/azcopy/azcopy.mk | tr -d '[:space:]')
mkdir -p azcopy-dist
# Strip with the build's own cross strip, exactly as target-finalize
# would (package/Makefile.in's STRIPCMD) -- `make azcopy` stops short
# of that step, and the difference is ~16 MiB.
#
# Resolved into an array and asserted to be exactly one, rather than
# $(echo <glob>). That shorthand degrades badly in both directions:
# zero matches leaves the literal pattern and >1 leaves a
# space-separated list, and each then fails at the strip invocation
# with an error naming a path that never existed. It is unambiguous in
# this tree today -- but "today" is doing real work in that sentence,
# and a second cross toolchain appearing under output/host/bin is
# exactly the kind of change nobody would think to re-check here.
mapfile -t strips < <(ls output/host/bin/*-linux-*-strip 2>/dev/null || true)
if [ "${#strips[@]}" -ne 1 ]; then
echo "::error::expected exactly one cross strip matching output/host/bin/*-linux-*-strip, found ${#strips[@]}: ${strips[*]-<none>}" >&2
exit 1
fi
cp output/target/usr/bin/azcopy azcopy-dist/azcopy
"${strips[0]}" --remove-section=.comment --remove-section=.note azcopy-dist/azcopy
( cd azcopy-dist && xz -9 -T0 -c azcopy > "azcopy-${ver}-armv7.xz" && rm -f azcopy )
( cd azcopy-dist && sha256sum "azcopy-${ver}-armv7.xz" > "azcopy-${ver}-armv7.xz.sha256" )
ls -l azcopy-dist/
- name: Upload azcopy asset
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: azcopy-dist-${{ github.sha }}
path: azcopy-dist/
if-no-files-found: error
retention-days: 14
# ============================================================================
# publish — attests provenance and creates the GitHub Release. Separate job
# for the elevated write scopes (contents, id-token, attestations) the build
# jobs must NOT have. See docs/ci.md#publish-job-scope.
# ============================================================================
publish:
name: Attest provenance + create GitHub Release
needs: [build, build-azcopy]
runs-on: ubuntu-26.04
if: startsWith(github.ref, 'refs/tags/')
permissions:
contents: write # create the Release and upload its assets
id-token: write # attest-build-provenance's OIDC token exchange
attestations: write # publish the attestation to this repo
steps:
- name: Download release asset bundle
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-dist-${{ github.sha }}
path: dist
- name: Download azcopy asset
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: azcopy-dist-${{ github.sha }}
path: dist
# The asset list is fixed; a file that did not survive the artifact
# round trip fails here rather than producing a release with a hole.
- name: Check every asset survived the artifact round trip
env:
RELEASE_DATE: ${{ needs.build.outputs.release_date }}
run: |
set -eu
ls -la dist/
for f in "release_${RELEASE_DATE}.7z" linux.img zImage_dtb zImage_dtb-rt SHA256SUMS buildroot.config linux.config linux-rt.config legal-info.tar.gz; do
[ -f "dist/$f" ] || { echo "::error::dist/$f is missing -- it went missing between the build job and this one" >&2; exit 1; }
done
( cd dist && sha256sum -c SHA256SUMS )
azc=(dist/azcopy-*-armv7.xz dist/azcopy-*-armv7.xz.sha256)
[ "${#azc[@]}" -eq 2 ] && [ -f "${azc[0]}" ] && [ -f "${azc[1]}" ] || { echo "::error::expected dist/azcopy-<ver>-armv7.xz and its .sha256 from the build-azcopy job" >&2; exit 1; }
- name: Attest build provenance for the image assets
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: |
dist/linux.img
dist/zImage_dtb
dist/zImage_dtb-rt
- name: Create draft GitHub Release
env:
GITHUB_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
RELEASE_DATE: ${{ needs.build.outputs.release_date }}
TAG: ${{ github.ref_name }}
run: |
set -eu
assets=(
dist/"release_${RELEASE_DATE}.7z"
dist/linux.img dist/zImage_dtb dist/zImage_dtb-rt
dist/SHA256SUMS dist/buildroot.config dist/linux.config dist/linux-rt.config
dist/legal-info.tar.gz
)
for f in dist/sdcard.img.xz dist/sdcard-full.img.xz; do [ -f "$f" ] && assets+=("$f"); done
assets+=(dist/azcopy-*-armv7.xz dist/azcopy-*-armv7.xz.sha256)
notes="Automated release build. Internal linux version (baked \`/MiSTer.version\`): ${RELEASE_DATE}. See SHA256SUMS for asset hashes and the attached build-provenance attestation (verify with \`gh attestation verify <asset> --owner ${{ github.repository_owner }}\`). The \`sdcard*.img.xz\` assets are standalone flashable installer images (ADR 0020); they are NOT part of release_${RELEASE_DATE}.7z and are not referenced by db.json.
**Complete corresponding source (GPL/LGPL):** every binary here is built from free/open-source software with Buildroot. \`legal-info.tar.gz\` (the main image AND the RT kernel, which is a package of the same build) ships the SBOM — \`manifest.csv\` listing each package's exact version, license, and pinned upstream source URL+hash — plus every license text, all local patches we apply, and \`buildroot.config\`. The upstream source archives themselves are deliberately NOT bundled: they are the freely-available, hash-pinned upstreams \`manifest.csv\` points to, and re-bundling ~1.4 GiB of them (591 MiB of that the linux-firmware blob alone) into every release only duplicated what upstream + this repo already provide. The complete corresponding source for every GPL/LGPL component is (a) the upstream archives at the exact versions/URLs in \`manifest.csv\`, byte-verifiable against their recorded hashes, plus (b) this repository at tag \`${{ github.ref_name }}\`, which carries the full Buildroot configuration and all applied patches and rebuilds the identical images with \`make\`. **Written offer:** for at least three years from this release, we will additionally provide a complete machine-readable copy of the corresponding source for any component on request — open an issue at https://github.com/${{ github.repository }}.
**azcopy (optional, ARMv7):** \`azcopy-<version>-armv7.xz\` is Microsoft's Azure Storage CLI, for backing the card's \`saves\`/\`savestates\`/\`screenshots\`/\`config\` up to Azure Storage from the board itself. It is a STANDALONE DOWNLOAD and is deliberately **not** in \`linux.img\`: at 39 MiB installed it would be the second-largest thing in the image after Samba, for a tool most owners will never run — and almost all of that size is Google Cloud Storage support that a MiSTer never executes (an empty Go binary is 1.2 MB; +Azure SDK 5.5 MB; +GCS 27.6 MB). Statically linked on purpose, so it keeps working if you roll back to an older image or to stock. To use it: \`xz -d\` it onto \`/media/fat\`, \`chmod +x\`, and set \`AZCOPY_JOB_PLAN_LOCATION\`/\`AZCOPY_LOG_LOCATION\` under \`/media/fat\` plus \`AZCOPY_BUFFER_GB=0.125\` — that last one matters, because AzCopy otherwise sizes its buffer cache at 1 GiB on a board with 511 MiB of RAM. Microsoft does not build or test AzCopy for 32-bit ARM; this build carries two local patches to make it compile and to stop every \`azcopy login\` dying on SIGILL. See \`docs/azcopy.md\` for the full write-up and the on-hardware test record.
**PREEMPT_RT beta (Linux 7.2):** \`zImage_dtb-rt\` is a separately compiled real-time kernel (docs/rt-beta-kernel.md, ADR 0021 as amended 2026-07-18) — never referenced by db.json, so the Downloader will not offer it. The ONE \`linux.img\` above already contains BOTH kernels' module trees, so no separate rootfs exists and — once the device is running THIS release's \`linux.img\` (a linux.img from before this release carries only 6.18 modules, and the RT kernel would boot with none of its drivers) — no extra flash is needed: opt in by copying \`zImage_dtb-rt\` to \`/media/fat/linux/\` and adding \`bootimage=/linux/zImage_dtb-rt\` to u-boot.txt (remove the line to roll back). \`linux-rt.config\` is its kernel config; its applied patches, licence texts and manifest row are inside \`legal-info.tar.gz\` (package \`linux-rt\`), covered by the corresponding-source note above. It is a MANUAL download only: the sdcard installer image deliberately does NOT carry it (ADR 0021 item 4, reversed 2026-07-27 — an unvalidated real-time kernel does not belong on every user's card). Status: build-verified only. Earlier pins booted and ran MiSTer on one DE10-Nano — 7.2-rc4 on 2026-07-20 and 7.2-rc7 on 2026-08-14 — but boot is a per-version claim, so treat the kernel in THIS release as unvalidated on hardware unless a maintainer says otherwise. (This paragraph is hand-maintained prose and its version can go stale ahead of the pin; \`linux-rt.config\` in this same release is authoritative for what was actually built.) The pin tracks the 7.2.y line as of 2026-08-17 — it no longer follows mainline \`-rc\`, so this caveat is re-opened by a point release rather than weekly. No latency measurement has been taken on any version."
# Upsert, not create-only: a tagged push already created the draft,
# so an opt-in full-sdcard dispatch (same tag) must ADD assets
# rather than fail on already-exists. See
# docs/ci.md#release-assets-array.
if gh release view "$TAG" >/dev/null 2>&1; then
gh release upload "$TAG" --clobber "${assets[@]}"
else
gh release create "$TAG" --draft --title "$TAG" --notes "$notes" "${assets[@]}"
fi
- name: Publish summary (job summary)
env:
RELEASE_DATE: ${{ needs.build.outputs.release_date }}
TAG: ${{ github.ref_name }}
run: |
{
echo "## 📦 Draft release created"
echo ""
echo "Draft GitHub Release **${TAG}** (internal version \`${RELEASE_DATE}\`); provenance attested for \`linux.img\`, \`zImage_dtb\` and \`zImage_dtb-rt\`."
echo ""
echo "> ⚠️ **This is a draft.** Publishing it — as a release *or* a pre-release — fires \`publish-db.yml\`, which publishes db.json to opted-in devices. For a smoke test, leave it a draft and delete it when done."
} >> "$GITHUB_STEP_SUMMARY"
dependency-graph:
name: Publish SBOM to the dependency graph
# publish, not build: nothing should appear in the graph for a release
# that failed to get created. `publish` needs `build` transitively, which
# is what makes the release-dist artifact below available.
needs: publish
runs-on: ubuntu-26.04
timeout-minutes: 15
# Same tag guard as `publish` (whose `if:` this does NOT inherit —
# `needs:` on a skipped job skips this one too, but the guard is repeated
# so the intent is readable here rather than inferred).
if: startsWith(github.ref, 'refs/tags/')
permissions:
contents: write # the dependency submission API requires it
steps:
# Unlike `publish` this job DOES check out: it runs
# scripts/sbom-to-dependency-snapshot.py. Nothing else here needs the
# tree, so the checkout is shallow by default.
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Pulls the whole release bundle (several GiB) for two small tarballs.
# That is deliberate: extracting the manifests from the assets that were
# actually published is what makes the graph and the release agree, and
# a ~1-minute artifact download is immaterial against a ~9-hour release
# pipeline. Do not "optimise" this into a second, separately-staged
# manifest artifact — that would reintroduce a way for the two to drift.
- name: Download release asset bundle
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: release-dist-${{ github.sha }}
path: dist
# One bundle since ADR 0030: legal-info.tar.gz covers the image, the RT
# kernel and the stage-1 packages (all packages of the one build). The
# snapshot names its manifest output/legal-info/manifest.csv, stable across
# releases and self-describing in the graph UI.
- name: Extract the SBOM manifest from legal-info.tar.gz
id: manifests
run: |
set -euo pipefail
[ -f dist/legal-info.tar.gz ] || { echo "::error::dist/legal-info.tar.gz is missing from the release-dist bundle" >&2; exit 1; }
mkdir -p "$RUNNER_TEMP/sbom/output"
tar -xzf dist/legal-info.tar.gz -C "$RUNNER_TEMP/sbom/output" legal-info/manifest.csv
printf -- '--manifest\n%s\n' "output/legal-info/manifest.csv=$RUNNER_TEMP/sbom/output/legal-info/manifest.csv" > "$RUNNER_TEMP/manifest-args.txt"
echo "manifest: $(wc -l < "$RUNNER_TEMP/sbom/output/legal-info/manifest.csv") lines (covers the image, the RT kernel and the stage-1 packages)"
- name: Resolve the ref to submit the snapshot under
id: ref
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
SHA: ${{ github.sha }}
TAG_REF: ${{ github.ref }}
run: |
set -euo pipefail
default_branch=$(gh api "repos/${GH_REPO}" --jq '.default_branch')
echo "default branch: ${default_branch}"
status=$(gh api "repos/${GH_REPO}/compare/${default_branch}...${SHA}" --jq '.status')
echo "compare ${default_branch}...${SHA}: ${status}"
case "$status" in
identical|behind)
echo "ref=refs/heads/${default_branch}" >> "$GITHUB_OUTPUT"
echo "on_default_branch=true" >> "$GITHUB_OUTPUT"
;;
*)
echo "ref=${TAG_REF}" >> "$GITHUB_OUTPUT"
echo "on_default_branch=false" >> "$GITHUB_OUTPUT"
echo "::warning title=Dependency graph will not be updated::Tagged commit ${SHA} is '${status}' relative to ${default_branch}, i.e. it is not on the default branch, so this snapshot is submitted under ${TAG_REF}. GitHub accepts such a snapshot and then ignores it -- /network/dependencies will keep showing the previous release's packages. Merge the tagged commit to ${default_branch} and re-tag if the graph should reflect it."
;;
esac
- name: Build the dependency snapshot from the SBOM
env:
SHA: ${{ github.sha }}
REF: ${{ steps.ref.outputs.ref }}
RUN_ID: ${{ github.run_id }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
run: |
set -euo pipefail
mapfile -t manifest_args < "$RUNNER_TEMP/manifest-args.txt"
if [ "${#manifest_args[@]}" -eq 0 ]; then
echo "::error::no manifests were staged -- the extract step should have failed first" >&2
exit 1
fi
# job-correlator is a HARDCODED literal, not github.workflow/job:
# GitHub keeps only the newest snapshot per (correlator, detector
# name), so each release must reuse this exact string to supersede
# the last one. Renaming the workflow must not orphan the series.
python3 scripts/sbom-to-dependency-snapshot.py \
"${manifest_args[@]}" \
--sha "$SHA" \
--ref "$REF" \
--job-id "$RUN_ID" \
--job-correlator 'release_buildroot-sbom' \
--job-html-url "$RUN_URL" \
--detector-url "$REPO_URL" \
--out "$RUNNER_TEMP/dependency-snapshot.json"
- name: Submit the snapshot to the dependency submission API
id: submit
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
ON_DEFAULT_BRANCH: ${{ steps.ref.outputs.on_default_branch }}
run: |
set -euo pipefail
resp="$RUNNER_TEMP/snapshot-response.json"
err="$RUNNER_TEMP/snapshot-response.err"
if ! gh api --method POST \
-H "Accept: application/vnd.github+json" \
"/repos/${GH_REPO}/dependency-graph/snapshots" \
--input "$RUNNER_TEMP/dependency-snapshot.json" \
> "$resp" 2> "$err"; then
echo "::error::POST /repos/${GH_REPO}/dependency-graph/snapshots failed. A 403 usually means the dependency graph is disabled for this repository (Settings -> Advanced Security -> Dependency graph); a 404 means the endpoint is unavailable for this repository type." >&2
# BOTH streams: `gh api` writes its own "(HTTP 4xx)" line to
# stderr but the API's JSON error body -- the part that says WHY,
# e.g. a per-field validation message -- to stdout, which is
# captured in $resp. Printing only stderr throws that away.
sed 's/^/ /' "$err" >&2 || true
sed 's/^/ /' "$resp" >&2 || true
exit 1
fi
cat "$resp"
result=$(jq -r '.result // ""' "$resp")
# Flattened to one line before it ever reaches $GITHUB_OUTPUT: a
# newline in an API-supplied value is how a `key=value` output line
# gets corrupted (and is the classic output-injection shape). It
# also has to survive being printed inside a markdown table row in
# the summary step below.
message=$(jq -r '.message // ""' "$resp" | tr '\n' ' ')
echo "message=${message}" >> "$GITHUB_OUTPUT"
if [ "$result" != "SUCCESS" ]; then
echo "::error::dependency snapshot was not accepted (result='${result}'): ${message}" >&2
exit 1
fi
# A 201 with SUCCESS still does NOT mean the graph was updated --
# that is exactly what the default-branch wording reports. Treat it
# as a hard failure when the ref step believed it WAS submitting for
# the default branch: something in that reasoning is wrong and
# silently shipping an unchanged graph is the bug being fixed.
case "$message" in
*"not for the default branch"*)
if [ "$ON_DEFAULT_BRANCH" = "true" ]; then
echo "::error::GitHub reports the snapshot is not for the default branch even though the tagged commit was verified to be on it: ${message}" >&2
exit 1
fi
echo "::warning title=Dependency graph not updated::${message}"
;;
esac
- name: Dependency-graph summary (job summary)
if: always() && steps.manifests.outcome == 'success'
env:
REF: ${{ steps.ref.outputs.ref }}
MESSAGE: ${{ steps.submit.outputs.message }}
OUTCOME: ${{ steps.submit.outcome }}
DEPS_URL: ${{ github.server_url }}/${{ github.repository }}/network/dependencies
run: |
set -eu
{
echo "## 🧾 SBOM published to the dependency graph"
echo ""
echo "| Fact | Value |"
echo "|---|---|"
echo "| Submitted under ref | \`${REF:-n/a}\` |"
echo "| Submit step outcome | ${OUTCOME:-n/a} |"
echo "| API message | ${MESSAGE:-n/a} |"
echo ""
echo "Packages per manifest:"
echo ""
echo '```'
jq -r '.manifests | to_entries[] | "\(.key): \(.value.resolved | length) packages"' \
"$RUNNER_TEMP/dependency-snapshot.json" 2>/dev/null || echo "(snapshot not built)"
echo '```'
echo ""
echo "View at ${DEPS_URL} (the graph can take a few minutes to reflect a new submission)."
} >> "$GITHUB_STEP_SUMMARY"