Skip to content

fix(s136): the boot refusal cases carry an egress allow-list, now tha… #185

fix(s136): the boot refusal cases carry an egress allow-list, now tha…

fix(s136): the boot refusal cases carry an egress allow-list, now tha… #185

Workflow file for this run

# 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"