Commit330b08aaRecorded2 Aug 2026Repositorysigil

Reach the tools this script declares, and fail in seconds when it cannot

Message

Three call sites ran OUTSIDE guix shell and therefore never consulted manifest.scm at all. Declaring a tool and reaching it are different things, and the evidence is that file and jq were ALREADY declared and still died on an azoth runner:

    release-build.sh: line 282: file: command not found

Wrapped, with the reason at each site:

  204  test-cli-exit-contract.sh  - needs python3 for check [4]
  282  file                       - asserts artifact type after each cross build
  305  generate-release-metadata  - needs jq, and runs AFTER all ten cross
                                    builds, so a miss surfaces ~90 minutes in

The metadata site passes its variable through env INSIDE the shell rather than as a prefix outside it. Both forms work for a plain guix shell, which preserves the environment; only this one also survives SIGILRELEASECONTAINER=1, where guix shell -C does not.

Added a preflight over the empirically-established at-risk set. The list is not imagined: it is what remained after taking every command token out of release-build.sh, test-cli-exit-contract.sh, generate-release-metadata.sh and with-zig and testing each against a runner guest's PATH. An earlier bound assembled from a hand-picked list reported one tool when there were four.

THE PREFLIGHT'S FIRST VERSION WAS FALSE-GREEN AND THE SABOTAGE TEST IS THE ONLY REASON THIS IS NOT SHIPPING BROKEN. It probed with the same non-pure shell as the build, so against a manifest containing no python at all it reported OK, having silently resolved the developer's own copy:

    python3   -> /home/daviwil/.guix-home/profile/bin/python3

The defect it exists to catch, passing its own test. A non-pure probe measures the machine it runs on; the question that matters is whether the MANIFEST supplies the tool, because that is what a runner has and the developer laptop is the one host where the answer is accidentally yes. The probe is now --pure.

Gated both directions, and the RED names the tool while the other five still resolve, so the failure is attributable rather than a probe falling over:

    modified manifest  -> rc=0, all six from .../yl0dgc6q...-profile
    pristine manifest  -> rc=1, "not supplied by manifest.scm: python3"

KNOWN LIMITATION, stated rather than discovered later: none of this is tested under SIGILRELEASECONTAINER=1. The script already carried that caveat on the registry-anchor gate; wrapping these three adds sites to an existing gap rather than opening a new one. Container mode remains unverified.

Not verified end to end: proving the exit-path gate now runs all 21 checks on a runner needs a full release build there, which additionally needs python in the runner's own package list. That is a separate change requiring a golden-image re-bake.

Changed
 scripts/release-build.sh | 95 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
 1 file changed, 92 insertions(+), 3 deletions(-)
Diff
scripts/release-build.shmodified
@@ -113,6 +113,65 @@ else
113
GUIX=(guix shell -m manifest.scm --)
114
fi
115
+116
# ---------------------------------------------------------------------------
+117
# PREFLIGHT — resolve every at-risk external tool BEFORE doing ninety minutes
+118
# of work that will need it.
+119
#
+120
# WHY THIS EXISTS: this script died twice on an azoth runner, each time on a
+121
# different missing tool, each time after real work. `jq' is reached at the very
+122
# LAST step, after all ten cross builds. Fixing tools one at a time as they
+123
# surface costs a full run per tool; enumerating the class costs seconds.
+124
#
+125
# THE TOOL LIST IS EMPIRICAL, NOT IMAGINED. It is the set found by taking every
+126
# command token out of release-build.sh, test-cli-exit-contract.sh,
+127
# generate-release-metadata.sh and with-zig, keeping those that resolve as real
+128
# commands, and testing each against a runner guest's PATH (2026-07-30). An
+129
# allowlist assembled from memory answers "are THESE tools present?" and cannot
+130
# answer "what is missing?" -- the earlier bound was produced that way and was
+131
# wrong, reporting one tool when there were four.
+132
#
+133
# The ambient-only survivors (sha256sum, awk, cp, mkdir, rm, grep) are NOT
+134
# listed: they come from %base-packages and were empirically present on the
+135
# runner. Only the manifest-supplied set is at risk, and that is what this
+136
# checks -- in the wrapped environment, which is where those call sites now run.
+137
#
+138
# `which' IS FIRST ON PURPOSE. It is the instrument, and it is declared in
+139
# manifest.scm like everything else. If it is ever absent, every lookup below
+140
# fails and the whole list reports MISSING -- which is loud and wrong in the
+141
# SAFE direction. A preflight that checks the easy dependencies and skips the
+142
# hard one is worse than no preflight, because it manufactures confidence.
+143
# ---------------------------------------------------------------------------
+144
#
+145
# *** THE PROBE MUST BE --pure, AND THIS IS THE WHOLE POINT OF IT. ***
+146
#
+147
# The first version of this preflight used "${GUIX[@]}", i.e. the same non-pure
+148
# shell as the build. It reported a confident OK against a manifest with no
+149
# python in it, because a non-pure `guix shell' inherits the ambient PATH and it
+150
# had quietly resolved the developer's own copy:
+151
#
+152
# python3 -> /home/daviwil/.guix-home/profile/bin/python3
+153
#
+154
# That is the exact defect this preflight exists to catch, passing its own test.
+155
# A non-pure probe measures THE MACHINE IT RUNS ON; the question that matters is
+156
# whether the MANIFEST supplies the tool, because that is what a runner guest
+157
# will have and the developer laptop is the one host where the answer is
+158
# accidentally yes. Caught only by deliberately sabotaging the manifest and
+159
# watching the gate stay green -- it was not visible by reading it.
+160
PREFLIGHT_GUIX=(guix shell --pure -m manifest.scm --)
+161
info "Preflight: resolving external tools in the build environment"
+162
preflight_missing=""
+163
for tool in which python3 file jq curl minisign; do
+164
if resolved=$("${PREFLIGHT_GUIX[@]}" which "$tool" 2>/dev/null); then
+165
printf ' %-9s -> %s\n' "$tool" "$resolved"
+166
else
+167
printf ' %-9s -> MISSING\n' "$tool"
+168
preflight_missing="$preflight_missing $tool"
+169
fi
+170
done
+171
[[ -z "$preflight_missing" ]] \
+172
|| die "preflight: not supplied by manifest.scm:$preflight_missing"
+173
echo " preflight: OK"
+174
175
# ---------------------------------------------------------------------------
176
# Bootstrap (mirrors the CI bootstrap step)
177
# ---------------------------------------------------------------------------
@@ -201,7 +260,19 @@ echo " default ZIP bundle build+run: OK"
260
# topics/sigil-cli-exit-path-contract.
261
# ---------------------------------------------------------------------------
262
info "Exit-path contract gate (static release binary)"
204
"$SRC/test/integration/test-cli-exit-contract.sh" "$STATIC_BIN" \
+263
# WRAPPED, and the absence of this wrapper was the bug. Check [4] of this gate
+264
# shells out to python3 to hold a port open. Unwrapped, python3 resolved from the
+265
# AMBIENT PATH -- which a developer laptop happens to carry and a runner guest
+266
# does not. Measured on an azoth runner 2026-07-30:
+267
#
+268
# test-cli-exit-contract.sh: line 356: python3: command not found
+269
# exit-path contract: 1 of 18 checks FAILED
+270
#
+271
# and note the denominator MOVED: 18 attempted there against 21 with python3
+272
# present, because the three serve-on-an-already-bound-port assertions never ran
+273
# at all. "1 of 18 FAILED" reads as "17 fine"; the truth was three checks
+274
# silently missing from the count.
+275
"${GUIX[@]}" "$SRC/test/integration/test-cli-exit-contract.sh" "$STATIC_BIN" \
276
|| die "exit-path contract gate FAILED on the release binary"
277
echo " exit-path contract: OK"
278
@@ -279,7 +350,12 @@ for config in "${CROSS_CONFIGS[@]}"; do
350
[[ "$config" == windows-* ]] && src_bin="$src_bin.exe"
351
[[ -f "$src_bin" ]] || die "expected binary missing: $src_bin"
352
cp "$src_bin" "$OUT/$(artifact_name "$config")"
282
file "$OUT/$(artifact_name "$config")"
+353
# WRAPPED for the same reason as the exit-path gate above. `file' IS declared
+354
# in manifest.scm and STILL died here on the azoth runner --
+355
# "release-build.sh: line 282: file: command not found" -- because an
+356
# unwrapped call site never consults the manifest at all. Declaring a tool and
+357
# reaching it are two different things.
+358
"${GUIX[@]}" file "$OUT/$(artifact_name "$config")"
359
done
360
361
# The glibc linux-amd64 binary can run on an FHS glibc x86_64 host. Skip
@@ -301,7 +377,20 @@ fi
377
info "Generating checksums and release metadata"
378
(cd "$OUT" && sha256sum sigil-* > SHA256SUMS)
379
304
RELEASE_REPO_URL="https://codeberg.org/sigil/sigil" \
+380
# WRAPPED: generate-release-metadata.sh uses `jq', which is declared in
+381
# manifest.scm and was still unreachable here for the same reason `file' was.
+382
#
+383
# The variable is passed through `env' INSIDE the shell rather than as a
+384
# `VAR=x "${GUIX[@]}" ...' prefix outside it. Both work for a plain `guix shell',
+385
# which preserves the environment; only this one also survives
+386
# SIGIL_RELEASE_CONTAINER=1, where `guix shell -C' does not. Removing the
+387
# ambiguity beats getting it right for one of the two modes.
+388
#
+389
# ORDERING NOTE, because it cost most of a run once: this is the LAST tool the
+390
# script needs and it runs AFTER all ten cross builds. A missing tool here
+391
# surfaces roughly ninety minutes in. That is the argument for the preflight
+392
# below rather than for fixing tools one at a time as they fail.
+393
"${GUIX[@]}" env RELEASE_REPO_URL="https://codeberg.org/sigil/sigil" \
394
"$SRC/scripts/generate-release-metadata.sh" "$VERSION" "$OUT" > "$OUT/v$VERSION.json"
395
cp "$OUT/v$VERSION.json" "$OUT/latest.json"
396