Repository navigation
1194 lines (1108 loc) · 58.6 KB
/
Copy pathci.yml
File metadata and controls
1194 lines (1108 loc) · 58.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
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# E2E for the browser ISO builder: Playwright drives the real WASM engine
# (real registry pulls through the relay).
#
# Two tiers, deliberately split:
# e2e — always runs, --grep-invert @full. Page load + inspect only,
# ~2 min, no ISO written. This is the merge gate.
# full-matrix — dispatch-only, one cell per base variant, each actually
# building and validating a multi-GB ISO.
#
# @full itself is a *format* proof: it asserts size >100 MB and the ISO9660
# CD001 magic at sector 16. The *boot* proof is the step chained behind it
# in full-matrix, which installs each built ISO with LUKS under QEMU and
# verifies the installed system reboots (tunaOS scripts/iso-e2e.sh). A green
# full-matrix cell therefore means: built in the browser, installed with
# full-disk encryption, and booted — not just "looks like an ISO".
# (Browser/CI boot parity history: tuna-os/tunaOS#673.)
#
# Expect this job to be mostly red today, and don't "fix" it by raising
# budgets. Re-running the failures with linear memory sampled found *two*
# independent engine bugs, not one (an earlier revision of this comment
# blamed the ceiling for all of them, which measurement refuted):
#
# 1. A non-deterministic hang fetching layers. bonito:kde froze at layer
# 38/65 on 114 MB, yellowfin:gnome at 50/65 on 121 MB — and at 47/65 in
# CI, i.e. a different layer per run. Go's js/wasm streamReader.Read
# selects only on the read promise, with no context case, so a stalled
# response body blocks forever and no deadline can break it.
# 2. The wasm32 4 GiB ceiling. Go targets 32-bit linear memory, so the
# unpacked tree, the EROFS image and the ISO share one address space.
# flounder:xfce is the *smallest* edition and therefore the only one to
# survive (1) and reach authoring, where it dies with
# `runtime: out of memory ... 4252303360 in use` at 4094 MB.
#
# (1) blocks large editions before (2) can, so fixing either alone unblocks
# almost nothing. Both live in tacklebox's engine — tuna-os/tacklebox#156.
name: CI
# Least-privilege default (iso-builder#117): no job here needs to write
# anything through GITHUB_TOKEN — artifact upload/download uses its own
# runtime token, R2 publishing uses R2_* secrets, and the deploy job uses
# CLOUDFLARE_API_TOKEN. Every job only reads its own repo (and, in wasm,
# public commit metadata from tuna-os/tacklebox via `gh api`), so
# contents: read covers all of them. Widen per-job with a job-level
# `permissions:` block if a future job genuinely needs more.
permissions:
contents: read
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
inputs:
run_full:
description: "Run full ISO build tests (@full)"
type: boolean
default: false
only:
description: "Limit full-matrix to edition slugs, comma-separated (e.g. yellowfin-gnome or bluefin-dakota,aurora-stable). Empty = all."
type: string
default: ""
keep_iso:
description: "Publish each built ISO to R2 instead of discarding it (boot testing)"
type: boolean
default: false
retain_candidate:
description: "Retain ONE Yellowfin GNOME candidate (max 10 GiB) in existing R2; NO automatic expiry or cleanup"
type: boolean
default: false
retain_iso_artifact:
description: "Upload the built ISO of the ONE only= cell as a chunked GitHub Actions artifact (no R2, no secrets; sha256 of ISO and each chunk in manifest.json). Needs run_full, exactly one only= slug, benchmark=false, keep_iso=false."
type: boolean
default: false
retain_iso_artifact_days:
description: "Retention days for the ISO artifact (1-90; repository maximum is 90)"
type: string
default: "3"
strict_roundtrip_test:
description: "Test exact retained ISO bytes under the strict harness. plain/luks: Skipjack GNOME only, observed plain or diagnostic LUKS, never qualification. both: plain+LUKS on one named qualify linux/amd64 cell; on main it emits an attested qualification receipt. Requires run_full, one only=, retain_iso_artifact; no benchmark or R2."
type: boolean
default: false
strict_install_mode:
description: "Strict-roundtrip-only install mode; plain and both require diagnostic_unqualified=false, luks requires true. Normal full runs still test both modes."
type: choice
options:
- plain
- luks
- both
default: plain
benchmark:
description: "Run each @full build three times (cold profiles) and attach median/range evidence"
type: boolean
default: false
pair:
description: "Run three cold/warm @full pairs per cell (warm reuses the cold profile and OPFS layer cache; fails if warm fetches any complete layer blob). Requires run_full; no benchmark, keep_iso, retain_* or strict_roundtrip_test."
type: boolean
default: false
runner:
description: "Where full-matrix cells run. github: GitHub-hosted ubuntu-24.04 (software rendering only, so only GNOME can be qualified). karnataka-gpu: the self-hosted tuna-iso-gpu scale set on karnataka, with KVM and a real DRM render node, for Pantheon, niri, KDE, XFCE and COSMIC. A preflight fails the cell closed if the host lacks either."
type: choice
options:
- github
- karnataka-gpu
default: github
diagnostic_unqualified:
description: "DIAGNOSTIC ONLY: let the named only= cell build a target the catalog gate marks unqualified. Result is labelled diagnostic-unqualified, never a qualification. Requires only=; refused with keep_iso."
type: boolean
default: false
schedule:
# Weekly: the app talks to live infra (relay, GHCR) that can drift.
- cron: "40 5 * * 1"
# Without this, every push to a ref (including repeated Renovate rebases of
# the same open PR) queues a brand-new run instead of superseding the last
# one. tacklebox hit exactly this — 26+ stale queued runs piled up and
# starved the shared macOS/Windows runner pool for hours (tuna-os/tacklebox#139).
# github.head_ref is empty on push/schedule, so this falls back to
# github.ref and still groups correctly for every trigger type.
#
# Dispatch runs get their own group keyed on run_id, so they neither cancel
# nor get cancelled. A full-matrix sweep is ~40 min of deliberate,
# hand-triggered work across nine cells, and under a shared branch key a
# single push to the PR wipes it out — that is exactly what happened to
# run 30510986807, where six cells died mid-pull as "The operation was
# canceled" and the sweep taught us nothing about those bases.
concurrency:
group: ci-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.head_ref || github.ref }}
cancel-in-progress: true
jobs:
# Decide, from the changed paths, which job families this push/PR can affect.
# Skipped jobs report "skipped", which satisfies a required check of the same
# name, so no check name changes. Anything that is not a plain PR/push (the
# weekly schedule, dispatch) or whose diff cannot be computed runs everything.
# The gated jobs also run when this job itself fails (result != success), so a
# broken selector can never turn a required check into a vacuous skip.
# native: native/** or this workflow (the Go module the three OS jobs test)
# code: anything except docs/examples/licence-type files
changes:
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
native: ${{ steps.sel.outputs.native }}
code: ${{ steps.sel.outputs.code }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- id: sel
env:
EVENT: ${{ github.event_name }}
BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
HEAD: ${{ github.event.pull_request.head.sha || github.sha }}
run: |
all() { echo native=true >> "$GITHUB_OUTPUT"; echo code=true >> "$GITHUB_OUTPUT"; exit 0; }
if [[ "$EVENT" != pull_request && "$EVENT" != push ]] || [[ -z "$BASE" || "$BASE" =~ ^0+$ ]]; then all; fi
if [[ "$EVENT" == pull_request ]]; then range="$BASE...$HEAD"; else range="$BASE $HEAD"; fi
# shellcheck disable=SC2086
git diff --no-renames --name-only -z $range > changed-paths 2>/dev/null || all
native=false code=false
while IFS= read -r -d '' path; do
case "$path" in
native/*|.github/workflows/ci.yml) native=true; code=true ;;
*.md|docs/*|LICENSE|examples/*|.editorconfig|.github/CODEOWNERS) ;;
*) code=true ;;
esac
done < changed-paths
echo "native=$native" >> "$GITHUB_OUTPUT"
echo "code=$code" >> "$GITHUB_OUTPUT"
# Three OS-native jobs, not one cross-compiled one: exec_windows.go,
# exec_darwin.go, blockdev_windows.go, blockdev_darwin.go,
# managed_windows.go, managed_darwin.go, and vm_darwin.go all carry
# _windows.go/_darwin.go filename suffixes, which are Go build
# constraints — go vet/test/build on ubuntu-latest silently never
# touches any of them (confirmed via `go list -f '{{.GoFiles}}'` on
# Linux: only the _linux.go set appears). A broken commit to any
# Windows- or macOS-only file would merge with fully green CI under
# the old single-job setup. Real native runners close that gap outright
# instead of fighting cross-compiled cgo/vet limitations.
qualification-contract:
# A strict sweep is dispatched from a ref whose tests already ran on its pull
# request; running the suite once per dispatched cell proved nothing the PR
# run had not (54 of 55 dispatch runs/week).
needs: changes
if: ${{ !cancelled() && github.event_name != 'workflow_dispatch' && (needs.changes.result != 'success' || needs.changes.outputs.code == 'true') }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.15"
- uses: actions/setup-node@v7
with:
node-version: 26
# The justfile/recipe/cleanup contract tests drive the real `just`;
# the browser live-root receipt tests build EROFS images with mkfs.erofs.
- name: install just and erofs-utils
run: sudo apt-get update -qq && sudo apt-get install -y -qq --no-install-recommends just erofs-utils
- run: python -m pip install pytest PyYAML
- run: python -m pytest -q tests
native-linux:
# See native-windows: same rationale for dispatch (55 runs/week x 5.7 min).
needs: changes
if: ${{ !cancelled() && github.event_name != 'workflow_dispatch' && (needs.changes.result != 'success' || needs.changes.outputs.native == 'true') }}
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: native/go.mod
cache-dependency-path: native/go.sum
- name: install GUI toolkit deps
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libgl1-mesa-dev xorg-dev libxxf86vm-dev \
libwayland-dev libxkbcommon-dev
- name: go vet
working-directory: native
run: go vet ./...
- name: go test
working-directory: native
run: go test -v -race -coverprofile coverage.out ./...
- name: coverage summary
working-directory: native
run: go tool cover -func coverage.out
- name: go build
working-directory: native
run: go build -o tacklebox-app .
native-windows:
# Dispatch is the full-matrix entry point, and a matrix sweep has no
# opinion about Windows. Nine cells used to drag nine copies of this
# job (and native-macos, and e2e) along with them — ~36 jobs proving
# nothing about the engine while competing for the same runner pool
# the cells need. main is not branch-protected, and required checks
# are evaluated from pull_request runs regardless, so skipping here
# cannot weaken a merge gate.
needs: changes
if: ${{ !cancelled() && github.event_name != 'workflow_dispatch' && (needs.changes.result != 'success' || needs.changes.outputs.native == 'true') }}
runs-on: windows-latest
# 15 was too tight — a real run hit exactly that ceiling and got
# cancelled outright (mid-build, not a hang) while every other job
# in the same run passed cleanly; past clean runs varied from ~10 to
# ~14.5 min. mingw/cgo + Fyne's dependency tree on windows-latest is
# just inherently slower and more variable than the other two
# platforms, so this gets real headroom instead of a repeat of the
# same borderline timeout.
timeout-minutes: 25
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: native/go.mod
cache-dependency-path: native/go.sum
# windows-latest images generally ship a usable mingw already, but
# pinning it explicitly here means this job doesn't quietly start
# failing (or quietly stop testing anything real) if a future image
# revision drops or relocates it.
- name: ensure mingw-w64 gcc is available
# windows-latest normally ships mingw-w64; the choco install cost ~0.9 min
# per run (162 runs/week). Install only when gcc is actually missing.
run: |
if (-not (Get-Command gcc -ErrorAction SilentlyContinue)) {
choco install mingw -y --no-progress
}
- name: go vet
working-directory: native
run: go vet ./...
# -tags=integration needs real WSL2/usbipd hardware wiring (see
# exec_windows.go) — excluded here same as native-linux excludes
# its own -tags=integration suite.
- name: go test
working-directory: native
run: go test -v -race -coverprofile coverage.out ./...
- name: coverage summary
working-directory: native
run: go tool cover -func coverage.out
- name: go build
working-directory: native
run: go build -o tacklebox-app.exe .
native-macos:
# See native-windows.
needs: changes
if: ${{ !cancelled() && github.event_name != 'workflow_dispatch' && (needs.changes.result != 'success' || needs.changes.outputs.native == 'true') }}
runs-on: macos-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: native/go.mod
cache-dependency-path: native/go.sum
- name: go vet
working-directory: native
run: go vet ./...
# -tags=integration needs a real QEMU helper VM boot (see
# exec_darwin_integration_test.go) — excluded here for the same
# reason native-linux excludes its own -tags=integration suite.
- name: go test
working-directory: native
run: go test -v -race -coverprofile coverage.out ./...
- name: coverage summary
working-directory: native
run: go tool cover -func coverage.out
- name: go build
working-directory: native
run: go build -o tacklebox-app .
# Build the engine from the pinned tacklebox. app/public/tbox.wasm is not in
# git (#75, #152): the hand-refreshed blob it replaced ("chore(wasm): rebuild
# tbox.wasm ...") shipped stale at least once — see 039ce59, "actually ship
# the udev-fallback wasm (prior commit captured stale binary)".
#
# The version comes from native/go.mod's existing tacklebox pin, so
# Renovate's digest bumps now move the CLI, the native app and the web
# engine together instead of letting the wasm drift on its own.
wasm:
# full-matrix needs the engine on dispatch; on PR/push it is only for e2e and
# deploy, which a docs-only change does not need.
needs: changes
if: ${{ !cancelled() && (github.event_name == 'workflow_dispatch' || needs.changes.result != 'success' || needs.changes.outputs.code == 'true') }}
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: native/go.mod
cache-dependency-path: native/go.sum
- name: install just without apt
uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4
# Building from native/'s module doesn't work: cmd/tbwasm pulls in
# packages native/ never imports, so its go.sum has no entries for them.
# Build from tacklebox's own module at the pinned commit instead.
# A Go pseudo-version carries a 12-char abbreviated commit, and
# actions/checkout cannot resolve one — it fetches the ref verbatim and
# git exits 1. Expand it to the full SHA via the API first.
- name: resolve pinned tacklebox commit
id: pin
env:
GH_TOKEN: ${{ github.token }}
run: just wasm-resolve-pin
- uses: actions/checkout@v7
with:
repository: tuna-os/tacklebox
ref: ${{ steps.pin.outputs.commit }}
path: .tacklebox
# wasm_exec.js is the toolchain's own shim and must match the compiler
# that produced the binary, so wasm-build takes it from the same
# toolchain rather than from git, where it could skew independently.
- name: build tbox.wasm, sync wasm_exec.js, report
run: just wasm-build
- uses: actions/upload-artifact@v7
with:
name: engine
path: |
app/public/tbox.wasm
app/public/wasm_exec.js
e2e:
# See native-windows. full-matrix needs [full-plan, wasm] only, so
# skipping this does not gate a sweep; deploy needs it but only fires
# on push to main, never on dispatch.
if: github.event_name != 'workflow_dispatch'
needs: wasm
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
# The engine is not in git; drop the freshly built one into app/public/
# so the suite tests the current source.
- uses: actions/download-artifact@v8
with:
name: engine
path: app/public/
- uses: actions/setup-node@v7
with:
node-version: 26
cache: "npm"
cache-dependency-path: e2e/package-lock.json
- name: Cache Playwright browsers
uses: actions/cache@v6
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('e2e/package-lock.json') }}
- name: install deps + chromium
env:
CACHE_HIT: ${{ steps.playwright-cache.outputs.cache-hit }}
run: |
sudo apt-get update -qq && sudo apt-get install -y -qq --no-install-recommends just
just e2e-install
# @full lives in full-matrix now — this job stays the fast merge gate
# on every push/PR rather than sometimes ballooning into an ISO build.
- name: run e2e (inspect flow, real network)
run: just e2e-gate
- name: upload screenshots
if: always()
uses: actions/upload-artifact@v7
with:
name: e2e-screenshots
path: e2e/screenshots/
if-no-files-found: ignore
- name: upload report
if: always()
uses: actions/upload-artifact@v7
with:
name: playwright-report
path: |
e2e/playwright-report/
e2e/test-results/
if-no-files-found: warn
# Last, after the uploads above have read test-results/: remove this run's
# browser profile and Playwright output (`just ci-cleanup`, scoped to this
# run id). always() so a failed or cancelled run is cleaned too; the guard
# covers a job that died before just was installed.
- name: cleanup (this run's browser profile and output)
if: always()
timeout-minutes: 5
run: |
if command -v just >/dev/null; then
just ci-cleanup
else
echo "just was never installed in this job; nothing was created"
fi
# scripts/verify-catalog.py shipped in #143 with nothing running it, which
# is the same failure mode it was written to close: a check nobody runs and
# a hand-typed list nobody compares are equally silent. The Go test in
# native/ pins app.js and catalog.go to EACH OTHER, so both agreeing on an
# image that does not exist is a green suite and a broken picker — exactly
# what ghcr.io/tuna-os/flounder:niri was until #143 removed it.
#
# The weekly schedule matters more than the PR run. These are tags on
# registries this project does not own; an upstream that retires
# ublue-os/bazzite:latest breaks the picker without a single commit here.
#
# The script fails only on a definitive 404. Timeouts, 5xx and rate limits
# are reported as INCONCLUSIVE and exit 0, so a registry outage does not
# redden unrelated pull requests. A parser that matches nothing exits 2,
# because a pass over zero refs would mean nothing.
catalog:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- name: resolve every offered image against GHCR
run: python3 scripts/verify-catalog.py
# GHCR existence is not correctness: a cell upstream stopped building
# still 200s until the registry eventually garbage-collects the tag
# (iso-builder#142's "orphaned tags" — flounder:cosmic, grouper:niri).
# Only tunaOS's own build-config.yml says what SHOULD be offered.
# scripts/verify-tunaos-build-config.py reads it directly and fails on
# any cell this repo offers that build-config.yml does not mark
# `build_image: true` for.
#
# Schedule/dispatch only, deliberately not on every push/PR: this repo
# does not own build-config.yml's edit cadence, and gating merges on
# another team's file is exactly the coupling #142 warned against. This
# is what the "Weekly: the app talks to live infra that can drift" cron
# comment above always promised and, per #142's own finding ("No job
# is gated on github.event_name == 'schedule' ... The declared drift
# detector was never implemented"), never actually did until now.
- name: cross-check catalog.go against tunaOS's build-config.yml
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
run: python3 scripts/verify-tunaos-build-config.py
# Resolves e2e/editions.json into the matrix, honouring the `only` input.
# gh cannot dispatch a single matrix cell, and re-running nine multi-GB
# pulls to iterate on one edition is indefensible — the first sweep did
# exactly that and spent eight cells learning one thing.
full-plan:
if: github.event_name == 'workflow_dispatch' && (github.event.inputs.run_full == 'true' || github.event.inputs.retain_candidate == 'true' || github.event.inputs.retain_iso_artifact == 'true' || github.event.inputs.strict_roundtrip_test == 'true')
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
editions: ${{ steps.pick.outputs.editions }}
count: ${{ steps.pick.outputs.count }}
steps:
- uses: actions/checkout@v7
- name: install just without apt
uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4
- id: pick
env:
ONLY: ${{ github.event.inputs.only }}
RETAIN_CANDIDATE: ${{ github.event.inputs.retain_candidate }}
RUN_FULL: ${{ github.event.inputs.run_full }}
BENCHMARK: ${{ github.event.inputs.benchmark }}
PAIR: ${{ github.event.inputs.pair }}
KEEP_ISO: ${{ github.event.inputs.keep_iso }}
DIAGNOSTIC_UNQUALIFIED: ${{ github.event.inputs.diagnostic_unqualified }}
RETAIN_ISO_ARTIFACT: ${{ github.event.inputs.retain_iso_artifact }}
STRICT_ROUNDTRIP_TEST: ${{ github.event.inputs.strict_roundtrip_test }}
STRICT_INSTALL_MODE: ${{ github.event.inputs.strict_install_mode }}
RETAIN_ISO_ARTIFACT_DAYS: ${{ github.event.inputs.retain_iso_artifact_days }}
# No --quiet: just's --quiet also discards the recipe's stdout.
run: just full-plan >> "$GITHUB_OUTPUT"
- run: echo "Building ${{ steps.pick.outputs.count }} edition(s)"
# One cell per base variant — the editions the picker actually offers.
# Coverage is per-*base*, not per-desktop, on purpose: the browser-side
# risk (compression codec, tar quirks, where the kernel/initramfs live,
# image size) tracks the base distro, while the desktop mostly changes
# payload size. Nine cells covers every base; 37 would mostly re-prove
# the same unpack path at ~4x the runner cost.
#
# Plus two *known-good upstream references*: projectbluefin/dakota:stable
# and ublue-os/aurora:stable (org-qualified refs in editions.json, so the
# tuna-os/ prefix below must not apply to them). The TunaOS images are
# still stabilising, so a red TunaOS cell is ambiguous — image bug or
# pipeline bug? The references resolve that: they are published, widely
# deployed images that are known to boot, so a red reference cell can only
# mean the pipeline (engine, relay, installer, boot chain) broke. Run just
# the pipeline check with `only: bluefin-dakota,aurora-stable`. Both flow
# through the relay, whose org allowlist covers projectbluefin and
# ublue-os (worker/cors-shim.js) — note the *deployed* relay only picks
# that up when the change reaches main (deploy job), so a dispatch from a
# PR branch that predates the deployed allowlist will 403 on these two.
# At ~3.1 GB (dakota) and ~3.6 GB (aurora) of compressed layers they sit
# in the same size band as yellowfin:gnome/bonito:kde, so expect the same
# two engine bugs above to bite until tacklebox#156 lands — the cells are
# still worth having, as the baseline that turns green first.
#
# Dispatch-only: each cell pulls a multi-GB image and writes a multi-GB
# ISO, so this is not a per-push gate.
full-matrix:
needs: [full-plan, wasm]
# karnataka-gpu routes the cell to the self-hosted ARC scale set
# tuna-iso-gpu (runner group must allow tuna-os/iso-builder). Smithay
# compositors and Pantheon paint nothing under software rendering on a
# hosted runner; tunaOS iso-e2e.sh switches to virtio-vga-gl +
# egl-headless on its own once a render node exists. Pull requests and
# pushes never set inputs, so they always stay on GitHub-hosted runners.
runs-on: ${{ github.event.inputs.runner == 'karnataka-gpu' && 'tuna-iso-gpu' || 'ubuntu-24.04' }}
# Widened from the workflow default for one purpose: a both-mode strict
# run on main attests its qualification receipt with
# actions/attest-build-provenance, which needs an OIDC token (id-token)
# and the attestations API. Nothing else in this job uses either.
permissions:
contents: read
id-token: write
attestations: write
# The strict same-byte route has bounded stages: 75 min to build, 30 to
# split the ISO, 30+5 to upload it, 5 to drop local chunks, 30 to download,
# 30 to verify, 10 to prepare, and 60 for a single-mode guest: 275 min
# total. strict_install_mode=both runs plain then LUKS in one harness
# invocation, so its guest step gets 120 (335 min), plus 5+10+5+10 for
# the main-only receipt build, attestation, bundle check and upload.
# Setup and the pinned harness checkout, output labeling, uploads and
# cleanup have their own step caps. The unchanged GitHub maximum of 360
# min also bounds their combined runtime; individual caps are not extra
# job time.
# Without the strict guest test, retained-ISO runs need 330 min for the
# same setup, browser, 25-minute guest check, artifact transfer, and uploads.
# Candidate retention keeps its existing 130-minute cap. Cold/warm pairs
# run six browser builds (300-minute build step) plus the default 30 min.
timeout-minutes: ${{ github.event.inputs.strict_roundtrip_test == 'true' && 360 || github.event.inputs.pair == 'true' && 330 || github.event.inputs.retain_iso_artifact == 'true' && 330 || github.event.inputs.retain_candidate == 'true' && 130 || 105 }}
strategy:
# A coverage sweep is worthless if the first red cell cancels the
# rest — the whole point is learning which bases are broken.
fail-fast: false
# Each cell pulls gigabytes through the relay; don't stampede it.
max-parallel: 3
# ref is what the builder is given; slug is a filename-safe id (no
# ':' or '/'), stored explicitly because artifact names reject those
# characters and GitHub expressions have no replace() to derive one.
matrix:
edition: ${{ fromJSON(needs.full-plan.outputs.editions) }}
steps:
- uses: actions/checkout@v7
timeout-minutes: 5
# Before the GPU preflight, which is a just recipe: the karnataka runner
# image has no just (run 42893991042 failed the preflight with
# "just: command not found", exit 127).
- name: install pinned just without apt
uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4
timeout-minutes: 5
- name: preflight GPU host (karnataka-gpu only)
if: github.event.inputs.runner == 'karnataka-gpu'
timeout-minutes: 15
run: just gpu-host-preflight
- name: preflight opt-in candidate retention
if: github.event.inputs.retain_candidate == 'true'
timeout-minutes: 5
env:
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
run: |
set -euo pipefail
# Test presence only; never print credentials or contact storage here.
for name in R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY R2_ENDPOINT R2_BUCKET; do
if [[ -z "${!name}" ]]; then
echo "::error::Required retention configuration $name is missing"
exit 1
fi
done
python3 -m venv "$RUNNER_TEMP/candidate-retention-venv"
"$RUNNER_TEMP/candidate-retention-venv/bin/python" -m pip install boto3
"$RUNNER_TEMP/candidate-retention-venv/bin/python" - <<'PY'
import botocore.session
model = botocore.session.get_session().get_service_model('s3')
assert 'IfNoneMatch' in model.operation_model('PutObject').input_shape.members, 'conditional PUT support required'
PY
# Do NOT add a disk-reclaim step here, and do not move the browser
# profile to a roomier filesystem. Both were tried and measured, and
# neither does anything.
#
# The reasoning that motivates them is sound in general — Chrome sizes
# the OPFS quota from the profile partition's free space, which is why
# fixtures.js keeps the profile off a 2 GB /tmp on dev boxes. It just
# does not bind on this runner:
#
# /dev/root 145G 58G used 88G avail (before any reclaim)
# /dev/root 145G 37G used 109G avail (after jlumbroso/free-disk-space)
# storage quota ≈ 10.7 GB free (the app, in BOTH cases)
#
# 21 GB reclaimed moved the quota by nothing. /mnt is not a separate
# filesystem on these images either — `df -h / /mnt` prints /dev/root
# twice — so staging the profile there relocates it nowhere. And 10.7 GB
# is the same figure himachal reports on completely different hardware,
# so this reads as a Chrome-side cap, not a disk-derived number.
#
# The reclaim also cost ~90 s per cell, which over a nine-cell sweep is
# real time spent buying nothing.
#
# If OPFS headroom ever does become the blocker, the lever is
# navigator.storage.persist() or spilling less into OPFS — not df.
# The whole point of this job is testing the engine, so it must be the
# one the wasm job built from the pin.
- uses: actions/download-artifact@v8
timeout-minutes: 5
with:
name: engine
path: app/public/
- uses: actions/setup-node@v7
timeout-minutes: 5
with:
node-version: 26
cache: "npm"
cache-dependency-path: e2e/package-lock.json
- name: Cache Playwright browsers
uses: actions/cache@v6
timeout-minutes: 5
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('e2e/package-lock.json') }}
- name: install deps + chromium
timeout-minutes: 20
env:
CACHE_HIT: ${{ steps.playwright-cache.outputs.cache-hit }}
# The hosted image ships Chromium's system libraries; the karnataka
# runner image does not, so it takes the normal --with-deps route.
PREINSTALLED_BROWSER_DEPS: ${{ github.event.inputs.runner == 'karnataka-gpu' && '' || 'true' }}
run: just e2e-install
- name: build ${{ matrix.edition.ref }} (${{ matrix.edition.base }})
id: browser-build
timeout-minutes: ${{ github.event.inputs.pair == 'true' && 300 || 75 }}
env:
TBOX_E2E_FULL: "1"
# TunaOS editions are stored unqualified (yellowfin:gnome) and get
# the tuna-os/ prefix; reference editions carry their own org
# (projectbluefin/dakota:stable) and pass through as-is.
TBOX_E2E_IMAGE: ${{ contains(matrix.edition.ref, '/') && matrix.edition.ref || format('tuna-os/{0}', matrix.edition.ref) }}
# The spec deletes the ISO right after `saved:` prints, so the
# per-sample TBOX_E2E_ISO_COPY in the run script below is what
# survives to boot — @full only ever proved format (size
# >100 MB, CD001 at sector 16), never boot. Multi-GB, so only
# ever a RUNNER_TEMP path, never an artifact.
TBOX_E2E_PLATFORM: ${{ matrix.edition.platform }}
# TEST-ONLY override of the catalog qualification gate. Set only
# when the dispatcher passed diagnostic_unqualified=true AND named
# the cell with only=; the spec refuses unless DIAGNOSTIC_TARGET
# equals the image it builds. Results are labelled
# diagnostic-unqualified and are never a qualification.
DIAGNOSTIC_ALLOW_UNQUALIFIED: ${{ github.event.inputs.diagnostic_unqualified == 'true' && github.event.inputs.only != '' && '1' || '' }}
DIAGNOSTIC_TARGET: ${{ contains(matrix.edition.ref, '/') && matrix.edition.ref || format('tuna-os/{0}', matrix.edition.ref) }}
TBOX_E2E_STRICT_OBSERVED: ${{ github.event.inputs.strict_roundtrip_test == 'true' && (github.event.inputs.strict_install_mode == 'plain' || github.event.inputs.strict_install_mode == 'both') && github.event.inputs.diagnostic_unqualified != 'true' && '1' || '' }}
STRICT_ROUNDTRIP_TEST: ${{ github.event.inputs.strict_roundtrip_test }}
STRICT_INSTALL_MODE: ${{ github.event.inputs.strict_install_mode }}
DIAGNOSTIC_UNQUALIFIED: ${{ github.event.inputs.diagnostic_unqualified }}
RETAIN_ISO_ARTIFACT: ${{ github.event.inputs.retain_iso_artifact }}
ONLY: ${{ github.event.inputs.only }}
KEEP_ISO: ${{ github.event.inputs.keep_iso }}
RETAIN_CANDIDATE: ${{ github.event.inputs.retain_candidate }}
# Fresh Playwright profile per sample, so every sample is cold.
# PAIR=true overrides this per sample: `just full-build` runs each
# warm sample in the preceding cold sample's profile.
TBOX_E2E_CACHE_MODE: "cold"
SLUG: ${{ matrix.edition.slug }}
BENCHMARK: ${{ github.event.inputs.benchmark }}
PAIR: ${{ github.event.inputs.pair }}
# The config's 900s per-test cap was sized against sailfin:base
# (2.5 GB, 4.2 min). Desktop editions are several times that, so
# the default would time out on payload size rather than on a bug.
# The 75-minute step cap lets Playwright report which stage stalled.
# The spec's 20-minute waits bound slow work in each phase.
# Watchdogs stop a dead engine in seconds or after a few minutes
# without progress. Playwright then reports the stalled stage.
run: just full-build
# Pinned to a commit SHA, not the default branch (iso-builder#117):
# scripts/iso-e2e.sh from this checkout is sudo-executed a few steps
# down, in the same job that later loads the R2 upload secrets — an
# unpinned clone would run whatever landed on tunaOS's default branch
# most recently, with root and (later in the job) those credentials in
# scope. actions/checkout (rather than a raw --depth=1 clone) is used
# so fetching an older SHA is reliable rather than depending on it
# still being the branch tip at clone time — same convention the wasm
# job above uses to pin tacklebox. Bump deliberately through review.
- name: checkout tunaOS test scripts (pinned)
if: github.event.inputs.strict_roundtrip_test != 'true' && github.event.inputs.pair != 'true'
uses: actions/checkout@v7
timeout-minutes: 5
with:
repository: tuna-os/tunaOS
ref: d583e85f6413260ccbc22af7e6f48c59246423c6
path: tunaos-e2e
- name: checkout current tunaOS strict harness (pinned)
if: github.event.inputs.strict_roundtrip_test == 'true'
uses: actions/checkout@v7
timeout-minutes: 5
with:
repository: tuna-os/tunaOS
ref: d583e85f6413260ccbc22af7e6f48c59246423c6
path: tunaos-e2e-current
- name: verify guest under QEMU (${{ matrix.edition.slug }})
if: github.event.inputs.strict_roundtrip_test != 'true' && github.event.inputs.pair != 'true'
timeout-minutes: 25
env:
QUALIFY: ${{ matrix.edition.qualify }}
SLUG: ${{ matrix.edition.slug }}
# Same resolution as the build step above. The harness needs the
# image ref for its generic (no-fisherman) install path: reference
# cells (aurora, bluefin) ship no fisherman, so iso-e2e.sh pulls
# this ref in the guest and installs with plain bootc
# (tuna-os/tunaOS#1030, tacklebox#178).
TBOX_E2E_IMAGE: ${{ contains(matrix.edition.ref, '/') && matrix.edition.ref || format('tuna-os/{0}', matrix.edition.ref) }}
run: just full-guest-verify
# Only a completed browser build is retainable. Guest failure remains a
# job failure; always() lets this bounded diagnostic step preserve its ISO.
# Cancellation/runner loss can still prevent retention: never claim it ran.
- name: retain opt-in diagnostic candidate (no automatic expiry)
if: ${{ always() && !cancelled() && github.event.inputs.retain_candidate == 'true' && steps.browser-build.outcome == 'success' }}
timeout-minutes: 20
env:
RETAIN_CANDIDATE: ${{ github.event.inputs.retain_candidate }}
RUN_FULL: ${{ github.event.inputs.run_full }}
ONLY: ${{ github.event.inputs.only }}
BENCHMARK: ${{ github.event.inputs.benchmark }}
KEEP_ISO: ${{ github.event.inputs.keep_iso }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
run: |
set -euo pipefail
echo "::warning::This diagnostic candidate has no automatic expiry. Existing R2 access remains unchanged."
python3 scripts/collect-candidate-provenance.py \
--root "$GITHUB_WORKSPACE" \
--iso "$RUNNER_TEMP/yellowfin-gnome.iso" \
--measurements e2e/test-results/build-measurements-qual-evidence-yellowfin-gnome-s1.json \
> e2e/test-results/candidate-producer-observations.json
"$RUNNER_TEMP/candidate-retention-venv/bin/python" scripts/retain-browser-candidate.py \
--iso "$RUNNER_TEMP/yellowfin-gnome.iso" \
--provenance e2e/test-results/candidate-producer-observations.json \
> e2e/test-results/candidate-retention-result.json
{
echo "### Diagnostic candidate retained (not qualified)"
echo "Chunked candidate bytes and manifest were verified by remote SHA-256 readback."
echo "No automatic expiry; storage charges can continue until separately authorized removal."
echo '```json'
cat e2e/test-results/candidate-retention-result.json
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
# Exact-bytes retention in GitHub, no R2 and no secrets: the ISO is split
# into hash-pinned 2 GiB chunks, uploaded uncompressed, then downloaded
# again and re-verified against the manifest AND the browser-measured ISO
# hash. Runs after the guest step even when it failed, so a built ISO that
# the guest rejects is still retained for diagnosis. Never a qualification.
- name: retain ISO as GitHub artifact (chunked, sha256-verified)
id: iso-artifact
if: ${{ always() && !cancelled() && github.event.inputs.retain_iso_artifact == 'true' && steps.browser-build.outcome == 'success' }}
timeout-minutes: 30
env:
SLUG: ${{ matrix.edition.slug }}
DIAGNOSTIC_UNQUALIFIED: ${{ github.event.inputs.diagnostic_unqualified }}
run: just iso-artifact-split
- name: upload ISO chunks
id: iso-artifact-upload
if: ${{ always() && !cancelled() && steps.iso-artifact.outcome == 'success' }}
# karnataka's uplink measured ~2.3 MB/s (run 42897853650 had uploaded
# 4.14 GB when the 30-minute limit hit); a ~10 GB ISO needs ~75 min.
timeout-minutes: ${{ github.event.inputs.runner == 'karnataka-gpu' && 120 || 30 }}
uses: actions/upload-artifact@v7
with:
name: iso-${{ matrix.edition.slug }}-run${{ github.run_id }}-a${{ github.run_attempt }}
path: ${{ runner.temp }}/iso-artifact/
# The chunks are an already-compressed ISO; deflate only costs CPU.
compression-level: 0
retention-days: ${{ github.event.inputs.retain_iso_artifact_days }}
if-no-files-found: error
- name: upload ISO manifest (small, fetch without the ISO)
if: ${{ always() && !cancelled() && steps.iso-artifact.outcome == 'success' }}
timeout-minutes: 5
uses: actions/upload-artifact@v7
with:
name: iso-${{ matrix.edition.slug }}-run${{ github.run_id }}-a${{ github.run_attempt }}-manifest
path: e2e/test-results/iso-artifact-manifest/manifest.json
retention-days: ${{ github.event.inputs.retain_iso_artifact_days }}
if-no-files-found: error
# Free the local copy first so the round trip reads GitHub's bytes, not ours.
- name: drop local chunks before round trip
if: ${{ always() && !cancelled() && steps.iso-artifact-upload.outcome == 'success' }}
timeout-minutes: 5
env:
SLUG: ${{ matrix.edition.slug }}
STRICT_ROUNDTRIP_TEST: ${{ github.event.inputs.strict_roundtrip_test }}
run: just iso-artifact-drop-local
- name: download ISO artifact again
if: ${{ always() && !cancelled() && steps.iso-artifact-upload.outcome == 'success' }}
timeout-minutes: ${{ github.event.inputs.runner == 'karnataka-gpu' && 90 || 30 }}
uses: actions/download-artifact@v8
with:
name: iso-${{ matrix.edition.slug }}-run${{ github.run_id }}-a${{ github.run_attempt }}
path: ${{ runner.temp }}/iso-roundtrip
- name: verify ISO artifact round trip
id: iso-roundtrip
if: ${{ always() && !cancelled() && steps.iso-artifact-upload.outcome == 'success' }}
timeout-minutes: 30
env:
SLUG: ${{ matrix.edition.slug }}
ARTIFACT: iso-${{ matrix.edition.slug }}-run${{ github.run_id }}-a${{ github.run_attempt }}
ISO_SHA256: ${{ steps.iso-artifact.outputs.iso_sha256 }}
ARTIFACT_ID: ${{ steps.iso-artifact-upload.outputs.artifact-id }}
STRICT_ROUNDTRIP_TEST: ${{ github.event.inputs.strict_roundtrip_test }}
DIAGNOSTIC_UNQUALIFIED: ${{ github.event.inputs.diagnostic_unqualified }}
RETAIN_ISO_ARTIFACT_DAYS: ${{ github.event.inputs.retain_iso_artifact_days }}
run: just iso-artifact-verify
- name: prepare strict inputs from round-tripped media receipts
id: same-byte-inputs
if: ${{ always() && !cancelled() && github.event.inputs.strict_roundtrip_test == 'true' && steps.iso-roundtrip.outcome == 'success' }}
timeout-minutes: 25
env:
SLUG: ${{ matrix.edition.slug }}
EDITION_REF: ${{ matrix.edition.ref }}
PLATFORM: ${{ matrix.edition.platform }}
ARTIFACT_NAME: iso-${{ matrix.edition.slug }}-run${{ github.run_id }}-a${{ github.run_attempt }}
ARTIFACT_ID: ${{ steps.iso-artifact-upload.outputs.artifact-id }}
REPOSITORY: ${{ github.repository }}
RUN_ID: ${{ github.run_id }}
RUN_ATTEMPT: ${{ github.run_attempt }}
SOURCE_SHA: ${{ github.sha }}
STRICT_INSTALL_MODE: ${{ github.event.inputs.strict_install_mode }}
DIAGNOSTIC_UNQUALIFIED: ${{ github.event.inputs.diagnostic_unqualified }}
run: just full-strict-inputs
- name: verify exact retained ISO under current strict harness
id: strict-guest
if: ${{ always() && !cancelled() && github.event.inputs.strict_roundtrip_test == 'true' && steps.iso-roundtrip.outcome == 'success' && steps.same-byte-inputs.outcome == 'success' }}
timeout-minutes: ${{ github.event.inputs.strict_install_mode == 'both' && 120 || 60 }}
env:
SLUG: ${{ matrix.edition.slug }}
STRICT_INSTALL_MODE: ${{ github.event.inputs.strict_install_mode }}
DIAGNOSTIC_UNQUALIFIED: ${{ github.event.inputs.diagnostic_unqualified }}
run: just full-guest-verify-strict
- name: disk after build
if: always()
timeout-minutes: 5
run: df -h /
# R2 for *published* ISOs (retain_iso_artifact above is the diagnostic
# path): these are 5-7 GB each, and the org already publishes ISOs this
# way (same R2_* org secrets as the desktop-image repos). Under browser-iso/ so they are obviously not
# release artifacts. This step only runs when every prior step passed,
# so a published ISO has cleared the LUKS install + reboot check above.
- name: publish ISO to R2
if: ${{ github.event.inputs.keep_iso == 'true' }}
env:
RCLONE_CONFIG_R2_TYPE: s3
RCLONE_CONFIG_R2_PROVIDER: Cloudflare
RCLONE_CONFIG_R2_REGION: auto
RCLONE_CONFIG_R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
RCLONE_CONFIG_R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
RCLONE_CONFIG_R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
SLUG: ${{ matrix.edition.slug }}
run: |
set -euo pipefail
ISO="${RUNNER_TEMP}/${SLUG}.iso"
if [ ! -f "$ISO" ]; then
echo "::error::keep_iso was set but $ISO does not exist — the build did not reach the save step"
exit 1
fi
sudo apt-get update -qq && sudo apt-get install -y -qq rclone
DEST="browser-iso/${GITHUB_RUN_ID}/${SLUG}.iso"
rclone copyto --log-level INFO --s3-no-check-bucket "$ISO" "R2:${R2_BUCKET}/${DEST}"
# Verify server-side rather than trusting the exit code.
#
# The first flounder:xfce upload (run 30597566765) transferred all
# 5.288 GiB and then failed with `NotImplemented: 501` before
# rclone's attempt 2 "succeeded" in 0.2s — i.e. it verified rather
# than re-uploaded. That 501 is not random: S3 and R2 cannot
# CopyObject an object larger than 5 GB without a multipart copy,
# and every desktop edition clears 5 GB. So the one thing this
# step must not do is infer a good object from a zero exit code.
local_size=$(stat -c%s "$ISO")
remote_size=$(rclone lsjson --s3-no-check-bucket "R2:${R2_BUCKET}/${DEST}" | jq -r '.[0].Size // -1')
if [ "$remote_size" != "$local_size" ]; then
echo "::error::R2 object is $remote_size bytes, local ISO is $local_size — upload did not land intact"
exit 1
fi
echo "verified: R2 object matches local size ($local_size bytes)"
sha=$(sha256sum "$ISO" | cut -d' ' -f1)
{
echo "### ${SLUG} ISO published"
echo ""
echo '```'
echo "r2://${R2_BUCKET}/${DEST}"
echo "bytes ${local_size}"
echo "sha256 ${sha}"
echo '```'
echo ""
echo "Size verified against the R2 object. sha256 is of the local file —"
echo "R2 exposes no usable checksum for a multipart object this size."
echo ""
echo "This ISO passed the LUKS install + reboot verification under QEMU."
} >> "$GITHUB_STEP_SUMMARY"
# The guest harness runs under sudo, so its output starts root-owned.
# Rename its qualification receipt before upload; then give the tree
# back to the runner user so upload-artifact can read it.
- name: label diagnostic output and make guest harness output readable
if: always()
timeout-minutes: 10
env:
SLUG: ${{ matrix.edition.slug }}
STRICT_ROUNDTRIP_TEST: ${{ github.event.inputs.strict_roundtrip_test }}
DIAGNOSTIC_UNQUALIFIED: ${{ github.event.inputs.diagnostic_unqualified }}