Repository navigation
479 lines (448 loc) · 24.6 KB
/
Copy pathpackage.yml
File metadata and controls
479 lines (448 loc) · 24.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
# SPDX-FileCopyrightText: Sudo Apt Holdings LLC
# SPDX-License-Identifier: Apache-2.0
#
# Slice 001 lines 9 and 10. This workflow builds the packaged binary on three operating
# systems and smokes it. What it proves and what it cannot are written out in
# docs/packaging.md and repeated by each job in its own summary, because an artifact from a
# runner is easy to mistake for evidence of something it is not.
#
# A runner has no desktop session. No job here opens a window, and none claims to.
name: package
# Corrected 2026-09-06, before this workflow had ever run. The push trigger was
# `branches: [main]`, so the one branch whose evidence depends on it, the slice branch, was
# the one branch it ignored, and pushing slice 001 produced no package run at all. Slice
# branches are where a packaging change is proven; main is where it has already been proven.
#
# Narrowed 2026-09-19. Between 2026-09-05 and 2026-09-07 this matrix ran 31 times on every
# push, 1 600 runner-minutes, most of them Windows and macOS, for a packaging path that
# slice 001 had already proven. It now runs when a slice or release tag is pushed, on a
# change to something packaging actually depends on, or by hand. A slice that touches
# packaging asks for the run with `workflow_dispatch` and cites the run id in its proof.
# Corrected again after slice 062. The tags-only trigger meant a change to a packaging input
# (a dependency, the toolchain, the Tauri shell, config) was first packaged at the tag, which is
# after the owner has approved the slice: slice 062 added `jose` and the first packaging run of
# it was the tag's, which failed. Branches are added beside tags, with the same path filter, so
# a change to those inputs is packaged on the branch that makes it. This is not the guard for a
# prod-only code defect: the filter below does not match `lib/`, and `scripts/prod_check.sh` in
# `mix gate` is what catches that class on every push.
on:
push:
branches: ['**']
tags: ['slice/**', 'v*']
paths:
- mix.exs
- mix.lock
- config/**
- src-tauri/**
- rust-toolchain.toml
- .tool-versions
- .github/workflows/package.yml
workflow_dispatch:
# Least privilege for the workflow's token (2026-09-23, OpenSSF Scorecard Token-Permissions). The
# default is read; the one job that needs more, the attesting one, declares it at the job level
# where a reviewer sees it beside the step that uses it.
permissions:
contents: read
jobs:
# Slice 002. The bill of materials is derived from `mix.lock` alone: it is the same document
# whichever runner produces it, and `mix trinity.sbom` needs the project compiled in the dev
# environment, which the three packaging jobs do not otherwise do. Generating it once here and
# handing it to each of them keeps the bill travelling with every binary without paying for a
# second full compile on three operating systems, and without giving Windows a chance to
# produce a bill that differs from Linux's for no reason a reader could act on.
sbom:
name: bill of materials
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1
with:
version-file: .tool-versions
version-type: strict
- run: mix deps.get
- name: Generate the bill of materials
run: mix trinity.sbom
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: sbom
path: sbom.cdx.json
if-no-files-found: error
package:
name: ${{ matrix.name }}
runs-on: ${{ matrix.os }}
needs: sbom
permissions:
contents: read
# Slice 002: build provenance is an attestation signed through GitHub's OIDC identity for
# this workflow run, recorded in the public transparency log. `id-token` mints that
# identity and `attestations` writes the record; neither grants anything else.
id-token: write
attestations: write
strategy:
fail-fast: false
matrix:
include:
- name: linux x86_64
os: ubuntu-latest
target: linux_x86_64
artifact: desktop_linux_x86_64
- name: macOS aarch64
os: macos-latest
target: macos_aarch64
artifact: desktop_macos_aarch64
- name: windows x86_64
os: windows-latest
target: windows_x86_64
artifact: desktop_windows_x86_64.exe
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1
with:
version-file: .tool-versions
version-type: strict
# The Zig version is read from .tool-versions rather than written here. Burrito 1.6.0
# compares Zig for equality, not a range, so a second copy of the number in this file
# would be a second thing to keep in step, and the one that drifts is always the copy.
- name: Read the pinned Zig version from .tool-versions
id: zig
shell: bash
run: echo "version=$(awk '$1=="zig"{print $2}' .tool-versions)" >> "$GITHUB_OUTPUT"
- uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1
with:
version: ${{ steps.zig.outputs.version }}
# The Rust version is not written here either: rustup reads rust-toolchain.toml, which
# is the pin (see NOTES.md D1: asdf has no rust plugin and ignores a rust line).
- name: Rust toolchain from rust-toolchain.toml
shell: bash
run: rustup show active-toolchain
# Tauri v2 on Linux needs these; macOS and Windows runners carry their own webviews.
- name: Tauri system libraries (Linux only)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev \
libayatana-appindicator3-dev librsvg2-dev patchelf
- name: Confirm the toolchain is the pinned one
shell: bash
run: |
set -euo pipefail
zig version
test "$(zig version)" = "${{ steps.zig.outputs.version }}"
elixir --version
- run: mix deps.get
# config/prod.exs sets cache_static_manifest; the endpoint raises at boot without it.
- run: mix assets.deploy
# --overwrite is not optional. Without it, and with a release directory already
# present, mix release prompts, gets no stdin, and exits 0 having built nothing.
# Slice 013: on Linux, mdex's NIF is built from source for Burrito's musl ERTS, linked
# through Zig (scripts/zig-cc-musl; config/config.exs names the three settings and
# NOTES.md finding 14 the measurement). rustup installed the musl standard library from
# rust-toolchain.toml above. macOS and Windows load the precompiled artifact for their
# native ERTS; the smoke step below is what says whether it loaded.
- name: Build the Burrito binary
shell: bash
env:
BURRITO_TARGET: ${{ matrix.target }}
MIX_ENV: prod
MDEX_NATIVE_BUILD: ${{ runner.os == 'Linux' && '1' || '' }}
TRINITY_NIF_TARGET: ${{ runner.os == 'Linux' && 'x86_64-unknown-linux-musl' || '' }}
CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER: ${{ runner.os == 'Linux' && format('{0}/scripts/zig-cc-musl', github.workspace) || '' }}
run: |
set -euo pipefail
# `mix deps.get` above runs in the default env. On the Windows runner the prod build
# then refused with
#
# Unchecked dependencies for environment prod:
# * daisyui (https://github.com/saadeghi/daisyui.git - v5.5.20)
# lock mismatch: the dependency is out of date
#
# A second `mix deps.get` under MIX_ENV=prod did NOT fix it: it reported "All
# dependencies have been fetched" and the release refused anyway, so the checkout on
# disk is what Mix disagrees with, not the environment. Both offenders are git
# `sparse` deps carrying `app: false, compile: false`; they exist for the asset build
# and nothing in the release links them. Cleaning them forces a fresh checkout.
# Windows only, because neither other runner has ever refused.
if [ "${{ runner.os }}" = "Windows" ]; then
mix deps.clean daisyui heroicons
fi
mix deps.get
mix release desktop --overwrite
- name: The artifact exists and is not empty
shell: bash
run: |
set -euo pipefail
ls -l burrito_out/
test -s "burrito_out/${{ matrix.artifact }}"
# Tauri resolves `externalBin: ["../burrito_out/desktop"]` to
# `desktop-<target triple>`; Burrito writes `desktop_<os>_<arch>`. Without this the
# shell's build script stops with `resource path ... doesn't exist`, which is exactly
# how the first run of this step failed. `ExTauri.run/1` does the same rename for
# production builds; the triple comes from rustc rather than a table in this file.
- name: Name the sidecar the way Tauri expects
shell: bash
run: |
set -euo pipefail
triple=$(rustc -vV | awk '/^host:/{print $2}')
if [ "${{ runner.os }}" = "Windows" ]; then dst="burrito_out/desktop-$triple.exe"
else dst="burrito_out/desktop-$triple"; fi
cp "burrito_out/${{ matrix.artifact }}" "$dst"
ls -l burrito_out/
# Item 2 of the G4 decision. `src-tauri/` exists from slice 001's ex_tauri.install, so
# the shell is buildable in CI. This compiles the Rust window; it does not run it, and
# no job here claims a window opened: a runner has no desktop session.
- name: Build the Tauri shell
shell: bash
run: cargo build --manifest-path src-tauri/Cargo.toml --locked
- name: The shell binary exists
shell: bash
run: |
set -euo pipefail
if [ "${{ runner.os }}" = "Windows" ]; then bin=src-tauri/target/debug/trinity.exe
else bin=src-tauri/target/debug/trinity; fi
ls -l "$bin"
test -s "$bin"
echo "SHELL_BIN=$bin" >> "$GITHUB_ENV"
# --no-halt is what makes the exit mean anything. Burrito launches the release as
# `-s elixir start_cli`, and the Elixir CLI halts when its command list is empty, so
# without --no-halt the binary exits 0 on its own and `--smoke` proves nothing.
- name: "Smoke test: boots, serves, exits by itself, leaves nothing behind"
if: runner.os != 'Windows'
shell: bash
run: |
set -euo pipefail
# Match OUR processes, not the machine's. The first version of this step diffed the
# whole `ps -eo pid,ppid,comm` table and failed on macOS because the runner's own
# daemons churn between the two samples: mdworker_shared exiting, CloudTelemetry
# starting, and the `ps` process itself differing. The artifact had launched, served
# and exited cleanly; the assertion was wrong, not the binary. "No process of ours
# remains" is the claim AC7 actually makes.
ours() { ps -eo pid,ppid,comm | grep -E 'burrito|beam\.smp|erl_child_setup|desktop_' | grep -v grep || true; }
ours > ps-before.txt
chmod +x "burrito_out/${{ matrix.artifact }}"
# Slice 032: the variable, not `--smoke`: Kernel.CLI read the argument as a file
# ("No file named --smoke", exit 1) and won the race against the halting Task on
# macOS in run 35608084951.
TRINITY_SMOKE=1 "./burrito_out/${{ matrix.artifact }}" --no-halt | tee smoke.log
sleep 3
ours > ps-after.txt
grep -q '^TRINITY_SMOKE_PORT=[0-9][0-9]*$' smoke.log
# Slice 013: the renderer's NIF loaded and rendered in this binary (exit 3 otherwise).
grep -q '^TRINITY_SMOKE_MARKDOWN=ok$' smoke.log
# Slice 032, AC7: a fake-vector search ran inside this binary through the vector
# store in force (exit 4 otherwise). The EXLA and SEMANTIC lines are recorded, not
# asserted: whether the XLA library loads in Burrito's ERTS is what they measure.
grep -q '^TRINITY_SMOKE_VEC=ok:' smoke.log
grep -E '^TRINITY_SMOKE_(EXLA|SEMANTIC)=' smoke.log
# Slice 100, AC9: FTS5 and the model cache bind (exit 5 and 6 otherwise); the watcher line
# is recorded, because a polling fallback is the skills registry's documented behaviour.
grep -q '^TRINITY_SMOKE_FTS5=ok$' smoke.log
grep -q '^TRINITY_SMOKE_MODEL_CACHE=ok:' smoke.log
grep -E '^TRINITY_SMOKE_WATCHER=' smoke.log
echo "--- ours, before ---"; cat ps-before.txt
echo "--- ours, after ----"; cat ps-after.txt
diff ps-before.txt ps-after.txt
- name: Smoke test on Windows
if: runner.os == 'Windows'
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$env:TRINITY_SMOKE = "1"
& ".\burrito_out\${{ matrix.artifact }}" --no-halt | Tee-Object smoke.log
if ($LASTEXITCODE -ne 0) { throw "smoke exited $LASTEXITCODE" }
if (-not (Select-String -Path smoke.log -Pattern '^TRINITY_SMOKE_PORT=\d+$')) {
throw "no port line"
}
if (-not (Select-String -Path smoke.log -Pattern '^TRINITY_SMOKE_MARKDOWN=ok$')) {
throw "the markdown renderer did not render in this binary (slice 013)"
}
# Slice 032, AC7, as on the other two: the vector search binds, the other two lines are recorded.
if (-not (Select-String -Path smoke.log -Pattern '^TRINITY_SMOKE_VEC=ok:')) {
throw "the vector search did not run inside this binary (slice 032)"
}
Select-String -Path smoke.log -Pattern '^TRINITY_SMOKE_(EXLA|SEMANTIC)='
# Slice 100, AC9, as on the other two.
if (-not (Select-String -Path smoke.log -Pattern '^TRINITY_SMOKE_FTS5=ok$')) {
throw "FTS5 did not work inside this binary (slice 100)"
}
if (-not (Select-String -Path smoke.log -Pattern '^TRINITY_SMOKE_MODEL_CACHE=ok:')) {
throw "the model cache did not resolve inside this binary (slice 100)"
}
Select-String -Path smoke.log -Pattern '^TRINITY_SMOKE_WATCHER='
# AC1's property on a runner, and AC6's per-OS cold start, which used to be skipped.
# `bash` on every OS, Windows included: the job that never curled was the one gap in
# slice 001's CI evidence, and it was a hole in this file rather than in the artifact.
- name: Serves HTTP 200, timed from launch
shell: bash
run: |
set -euo pipefail
# `date +%s%3N` is a GNU extension: BSD date on the macOS runner emitted
# "17887458973N" and the arithmetic died with "value too great for base". python3 is
# present on all three runner images and gives the same number everywhere.
now_ms() { python3 -c 'import time;print(int(time.time()*1000))'; }
start=$(now_ms)
PHX_SERVER=true "./burrito_out/${{ matrix.artifact }}" --no-halt > serve.log 2>&1 &
PID=$!
# Match the ENDPOINT line, not any loopback address. `grep -o '127.0.0.1:[0-9]*' |
# head -1` picked the wrong port on Windows and only on Windows: there,
# ExTauri.ShutdownManager's heartbeat listens on a **TCP** socket because the BEAM
# cannot listen on a Unix domain socket on that OS, so serve.log reads
#
# [ExTauri.ShutdownManager] Started - heartbeat monitoring active on 127.0.0.1:58911
# Running TrinityWeb.Endpoint with Bandit 1.12.5 at 127.0.0.1:58912 (http)
#
# and the first match is the heartbeat. macOS and Linux use a Unix socket, print no
# loopback line before the endpoint, and were right by luck.
for _ in $(seq 1 240); do
PORT=$(grep -oE 'Running TrinityWeb\.Endpoint with Bandit [0-9.]+ at 127\.0\.0\.1:[0-9]+' serve.log \
| head -1 | grep -oE '[0-9]+$' || true)
[ -n "${PORT:-}" ] && break
sleep 0.5
done
# No endpoint line in two minutes says nothing on its own either: runs 35512430836
# (slice/011), 35515556177 (slice/012) and 35521749862 all failed here on macOS and
# Windows with an empty step log. The log the binary wrote goes to stderr first.
if [ -z "${PORT:-}" ]; then
echo "no 'Running TrinityWeb.Endpoint' line in serve.log after 120 s" >&2
echo "--- serve.log ---" >&2; tail -60 serve.log >&2
exit 1
fi
# Bounded. The first version of this loop had no limit and the Windows runner sat in
# it until the run was cancelled by hand: a step that hangs reports nothing and
# fails nothing, which is the same shape as a workflow that never fires.
ok=0
for _ in $(seq 1 600); do
if curl -sS -o /dev/null "http://127.0.0.1:$PORT/" 2>/dev/null; then ok=1; break; fi
sleep 0.1
done
if [ "$ok" != "1" ]; then
echo "no HTTP response on port $PORT after 60 s" >&2
echo "--- serve.log ---" >&2; tail -40 serve.log >&2
exit 1
fi
elapsed=$(( $(now_ms) - start ))
# A fresh data directory has no model key, so `/` answers 302 to `/setup` (slice 100,
# AC11): run 37743757349 failed all three OSes on that redirect. Follow it, and require
# that it lands on `/setup`, so a redirect anywhere else still fails here.
answer=$(curl -sS -L -o /dev/null -w '%{http_code} %{url_effective}' "http://127.0.0.1:$PORT/")
code=${answer%% *}
landed=${answer#* }
echo "HTTP $code on port $PORT at $landed"
case "$landed" in
"http://127.0.0.1:$PORT/" | "http://127.0.0.1:$PORT/setup") ;;
*) echo "GET / ended at $landed, neither / nor /setup" >&2; exit 1 ;;
esac
echo "COLD_START_MS=$elapsed" | tee -a "$GITHUB_ENV"
echo "### ${{ matrix.name }}: cold start to first HTTP 200: **${elapsed} ms**" >> "$GITHUB_STEP_SUMMARY"
# A non-200 says nothing on its own: run 35518054546 (slice 013) printed "HTTP 500"
# and nothing else, and the log that would have named the exception was never
# read. The body and the tail of serve.log go to stderr before the step fails.
if [ "$code" != "200" ]; then
echo "--- body of GET / ---" >&2; curl -sS -L "http://127.0.0.1:$PORT/" | head -c 2000 >&2; echo >&2
echo "--- serve.log ---" >&2; tail -60 serve.log >&2
fi
test "$code" = "200"
# Stopping it, and Windows needs its own verb. `kill` from Git-bash does not stop a
# native Windows process, so the backgrounded .exe outlived the step and the job hung
# long past the bounded loops above: run 34078281292, cancelled by hand. `taskkill
# /T` takes the wrapper and its BEAM together, which on Windows is also the only
# thing that clears finding F1's orphan.
if [ "${{ runner.os }}" = "Windows" ]; then
taskkill //PID "$PID" //T //F >/dev/null 2>&1 || true
taskkill //IM desktop_windows_x86_64.exe //T //F >/dev/null 2>&1 || true
else
CHILD=$(pgrep -P "$PID" 2>/dev/null || true)
kill "$PID" || true
# The wrapper does not forward termination to the BEAM it launched: finding F1,
# re-measured at G4 as an orphan still serving 200 past 7.6 s. Kill the child
# explicitly or the runner leaves it behind.
[ -n "$CHILD" ] && kill "$CHILD" 2>/dev/null || true
fi
# Slice 002. The bill of materials travels with the binary rather than being published
# somewhere a reader has to go and find: whoever has the artifact has the list of what is
# in it, and the bill states in its own metadata what it does not cover.
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: sbom
path: burrito_out
# The bill is placed beside the binary rather than listed as a second upload path, so that
# both land at the root of the artifact. Listing them separately makes upload-artifact take
# the workspace as their common ancestor and file the binary under `burrito_out/` inside the
# zip, which is a layout change every earlier release's consumers would have to absorb for
# no benefit.
#
# Corrected 2026-09-23, because the first version of this comment named a cause that is not
# true. It said `gh run download --name` refuses the two-path layout with "would result in
# path traversal". It does refuse, but the layout has nothing to do with it: gh 2.45.0 (the
# version in Ubuntu's archive) raises that error for any artifact when extracting into the
# current directory, including a pre-slice-002 single-file one, and passing `-D <dir>`
# succeeds for both. gh 2.63.2 downloads both without `-D`. The error was measured against
# unlike invocations and read as a regression it was not.
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: ${{ matrix.artifact }}
path: |
burrito_out/${{ matrix.artifact }}
burrito_out/sbom.cdx.json
if-no-files-found: error
# Build provenance: a signed statement of which workflow, at which commit, produced these
# exact bytes. It proves where the artifact came from, not what it does. Verified in this
# same job, because an attestation nobody checks is a file rather than an assurance.
- name: Attest the artifact's provenance
id: attest
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: burrito_out/${{ matrix.artifact }}
- name: Verify the attestation, and record the result in the run summary
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -o pipefail
gh attestation verify "burrito_out/${{ matrix.artifact }}" \
--repo "${{ github.repository }}" 2>&1 | tee attestation.txt
# `gh attestation verify` draws its result with carriage returns, which the log
# viewer collapses to nothing. One plain line the log keeps, reached only because
# `set -e` and `pipefail` end the step on a failed verification.
echo "gh attestation verify: PASS for ${{ matrix.artifact }}"
{
echo "### Build provenance: ${{ matrix.artifact }}"
echo
echo '```'
cat attestation.txt
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: always()
with:
name: launch-log-${{ matrix.target }}
path: |
smoke.log
serve.log
if-no-files-found: warn
# Line 10. Each job states, in its own summary, which of the two things it did, and
# every job here did the second one.
- name: State what this job did not prove
if: always()
shell: bash
run: |
{
echo "## ${{ matrix.name }}: what this run does and does not establish"
echo
echo "**Established:** the artifact builds on ${{ matrix.os }}; it launches and"
echo "reaches serving; under \`--no-halt --smoke\` it exits by itself and the"
echo "process list is unchanged either side; the launch log is attached."
echo
echo "**Also established:** the Tauri **shell compiles** on this OS"
echo "(\`cargo build --manifest-path src-tauri/Cargo.toml --locked\`), and cold"
echo "start to first HTTP 200 was measured at **${COLD_START_MS:-not measured} ms**."
echo
echo "**Not established, and not claimed:** the shell was **built, never run.**"
echo "No native window was opened. This job smoked the **sidecar alone, with no"
echo "display**: it did not run the shell under \`xvfb-run\` and it did not use a"
echo "desktop session, because this runner has none. No screenshot exists here;"
echo "the only screenshot of a real window in this project is the owner's, on"
echo "Linux, at slices/001-packaging-spike/proof/. First **paint** was not"
echo "measured. Nothing here speaks to signing or notarisation."
} >> "$GITHUB_STEP_SUMMARY"