Skip to content

ci: a three-state contract for verification scripts, and a closure check that runs - #549

Merged
Sunrisepeak merged 1 commit into
mainfrom
ci/verification-contract
Aug 8, 2026
Merged

ci: a three-state contract for verification scripts, and a closure check that runs#549
Sunrisepeak merged 1 commit into
mainfrom
ci/verification-contract

Conversation

@Sunrisepeak

Copy link
Copy Markdown
Member

Split out of one large PR so CI cost matches risk. Touches no pkgs/ file, so the per-package install tests skip entirely.

The three-state contract

0 proven / 1 broken / 2 inconclusive / 3 could-not-run-here, in .agents/tools/README.md, obeyed by 11 scripts. Callers must map 3 to "not run", never to a pass.

It exists because skip() in selfcontained-check.sh used to exit 0 — so a machine without bwrap printed ✓ S1-S4 pass for a check that ran nothing. Applying it found two more: verify-host-link's check 4 and verify-stack's app-RPATH cell both printed a pass with no patchelf present.

Build inputs, not just outputs

build-in-subos.sh verified the produced payload and never what the build linked against. Sealed bwrap was tried first and does not work here (the subos has no POSIX userland — clean and dirty builds fail alike), so it parses configure logs, filtered to resolved records. Reproduces both known instances and found a third: the --deps .pc rewrite patched only prefix=, leaving libdir=/usr/lib in 40 files.

Declared deps vs real DT_NEEDED

dep-closure-check.sh, per package. D1 direct declaration required — a transitive dep is not in the closure. D2 if the payload uses our interpreter, a soname with no provider is fatal, because our ld.so's cache path exists on no machine and there is no host fallback.

The tooling step matters as much as the script: linux-install-test is a separate job from linux-test, so lua5.4 was absent and the check exited 3 every run — "not evaluated", job green. After fixing: 20 passes, 6 not-evaluated, 0 failures over a 25-package set.

Two uninstall tolerances

Each keyed on its exact diagnostic, not on the package type alone:

Plus: posix-test.sh no longer floods the log with hundreds of ignored null byte in input warnings.

🤖 Generated with Claude Code

…eck that runs

Split out of the single large PR so its CI cost matches its risk: this touches
no pkgs/ file at all, so the per-package install tests skip entirely.

THE CONTRACT

0 proven / 1 broken / 2 inconclusive / 3 could-not-run-here, written into
.agents/tools/README.md and obeyed by eleven scripts. A caller must map 3 to
"not run" and must never fold it into a pass.

It exists because skip() in selfcontained-check.sh used to exit 0, and 0 is
what the caller reads as "S1-S4 pass" -- so a machine without bwrap printed a
tick for a check that ran nothing. Applying the contract turned up two more of
the same: verify-host-link's check 4 and verify-stack's app-RPATH cell both
printed a pass with no patchelf present.

BUILD INPUTS, NOT JUST OUTPUTS

build-in-subos.sh verified the produced payload and never what the build linked
against, so a build configured entirely against /usr passed "no host
references". The sealed-bwrap approach was tried first and does not work here
(the subos has no POSIX userland, so clean and dirty builds fail alike);
configure-log parsing instead, filtered to resolved records. It reproduces both
known instances -- the host libzstd in the LLVM tree behind our mesa, and a
constructed case -- and found a third: the --deps .pc rewrite patched only
prefix=, leaving libdir=/usr/lib in 40 files.

DECLARED DEPS vs REAL DT_NEEDED

dep-closure-check.sh, run per package from posix-test.sh. D1: a soname provided
by an installed package must be declared DIRECTLY, because a transitive dep does
not put its libdir in the closure. D2: if the payload uses our interpreter, a
soname with no provider is fatal -- our ld.so has a cache path that exists on no
machine, so there is no host fallback behind it.

Strictness follows the payload's own state (interpreter, or an RPATH into
xpkgs), so host-integrated packages are reported and not failed. A check that
fires on correct recipes gets switched off.

The tooling step matters as much as the script: linux-install-test is a separate
job from linux-test, so lua5.4 was absent and the check exited 3 on every run --
"not evaluated", job green. Measured after fixing it: 20 passes, 6
not-evaluated, 0 failures across a 25-package changed set.

Two uninstall tolerances, each keyed on its exact diagnostic rather than on the
package type alone: a type="config" package registers no xvm version (11 of the
12 in this index call xvm.add zero times), and a bump PR cannot remove the
xlings it is running. The latter is new since #543 -- overlaying the recipe into
the index made it install as xim:xlings rather than local:xlings, which the
running-binary guard matches. Left alone it is red on every future release bump.

Also stops posix-test.sh from printing hundreds of "ignored null byte in input"
warnings: it read ELF magic with head -c4 inside a command substitution while
walking the whole store.
Sunrisepeak pushed a commit that referenced this pull request Aug 7, 2026
Batch 2 of the index-wide sweep. Ten packages, so the install test stays short.
The guard that enforces the rule follows in its own PR once both batches have
landed -- it cannot pass while either half still has bare names, and a guard
merged red is a guard someone turns off.

Rationale, evidence and method are in part 1.

cpp is worth naming separately. Its uninstall fails for a reason that has nothing
to do with deps:

    uninstall failed: xvm removal selection failed for xim:cpp@gnu:
    removal version is not registered (target='cpp', version='gnu')

It has no config() and calls xvm.add zero times -- the norm for its type, since
11 of the 12 `type = "config"` recipes here register nothing. The install test
tolerates exactly that diagnostic as of #549, so merge that first. The question
underneath it (should removing a package that registered no version be a no-op,
so the recipe's own uninstall() cleanup actually runs?) is xlings-side and filed
as openxlings/xlings#503.

Verified: each file re-parsed and its deps re-enumerated per platform; stripping
the namespace prefixes from both sides makes every one byte-identical to main.
1116 static tests pass.
@Sunrisepeak
Sunrisepeak merged commit a2a180f into main Aug 8, 2026
8 checks passed
Sunrisepeak pushed a commit that referenced this pull request Aug 8, 2026
Batch 2 of the index-wide sweep. Ten packages, so the install test stays short.
The guard that enforces the rule follows in its own PR once both batches have
landed -- it cannot pass while either half still has bare names, and a guard
merged red is a guard someone turns off.

Rationale, evidence and method are in part 1.

cpp is worth naming separately. Its uninstall fails for a reason that has nothing
to do with deps:

    uninstall failed: xvm removal selection failed for xim:cpp@gnu:
    removal version is not registered (target='cpp', version='gnu')

It has no config() and calls xvm.add zero times -- the norm for its type, since
11 of the 12 `type = "config"` recipes here register nothing. The install test
tolerates exactly that diagnostic as of #549, so merge that first. The question
underneath it (should removing a package that registered no version be a no-op,
so the recipe's own uninstall() cleanup actually runs?) is xlings-side and filed
as openxlings/xlings#503.

Verified: each file re-parsed and its deps re-enumerated per platform; stripping
the namespace prefixes from both sides makes every one byte-identical to main.
1116 static tests pass.
Sunrisepeak added a commit that referenced this pull request Aug 8, 2026
* fix(deps): qualify bare dep names, part 2 of 2

Batch 2 of the index-wide sweep. Ten packages, so the install test stays short.
The guard that enforces the rule follows in its own PR once both batches have
landed -- it cannot pass while either half still has bare names, and a guard
merged red is a guard someone turns off.

Rationale, evidence and method are in part 1.

cpp is worth naming separately. Its uninstall fails for a reason that has nothing
to do with deps:

    uninstall failed: xvm removal selection failed for xim:cpp@gnu:
    removal version is not registered (target='cpp', version='gnu')

It has no config() and calls xvm.add zero times -- the norm for its type, since
11 of the 12 `type = "config"` recipes here register nothing. The install test
tolerates exactly that diagnostic as of #549, so merge that first. The question
underneath it (should removing a package that registered no version be a no-op,
so the recipe's own uninstall() cleanup actually runs?) is xlings-side and filed
as openxlings/xlings#503.

Verified: each file re-parsed and its deps re-enumerated per platform; stripping
the namespace prefixes from both sides makes every one byte-identical to main.
1116 static tests pass.

* fix(shortcut-tool, code): wscript blocks forever in a non-interactive session

windows-test on this branch stopped dead and had to be cancelled. The last thing
in the log was vc6's compat-mode `reg add` printing "The operation completed
successfully." -- then nothing.

The next call is vc6's config() running `shortcut-tool create`, and
shortcut-tool ran `wscript <vbs>`. wscript is the GUI-mode Windows Script Host:
it needs a window station, and in a non-interactive session (CI, a service,
WinRM, a scheduled task) it does not get one and never returns. cscript is the
console host, which is what automation wants. Nothing is printed and there is no
timeout, so from outside it is indistinguishable from a slow download -- the
same reason rust's io.read() cost four hours before anyone could see it.

Both call sites are fixed: shortcut-tool.lua and a second copy in code.lua,
found by sweeping for it rather than by waiting for another job to hang. Neither
had ever been install-tested on Windows -- the per-package test only runs
recipes in a PR's changed set, and vc6 entering it via the namespace sweep is
the only reason this ran at all.

check-no-blocking-input.sh now rejects wscript alongside unguarded io.read: same
class, one layer down, and the remedy is a fixed string worth printing. Verified
both ways -- PASS on the fixed tree, FAIL naming file and line when wscript is
put back.

Also worth recording what the log shows above the hang: `[warn] skipping
shortcut-tool: resource has neither url nor source for windows@0.0.1`. The
package it depends on installs no payload at all, so this is a second question
for vc6 (is the shortcut step meaningful on a runner?) that is not this PR's.

* ci: bound each install/uninstall so a hang names itself

I diagnosed the vc6 stall as wscript and was wrong -- the job that hung ran
293a1a7, with the cscript fix in it, and stopped at exactly the same point. I
have now guessed twice, so this stops guessing and makes the hang observable
instead.

Every install and uninstall runs under a 1200s limit. On Windows that means
Start-Process with output redirected to a file, and on timeout: kill the process
TREE (the blocked thing is usually a child -- cscript, an installer -- so
killing xlings alone leaves the runner occupied), then print the last 40 lines
of what was captured and fail as `<pkg> (install-timeout)`.

The tail is the point. GitHub serves no logs for an in-progress job, so a stall
today shows a log that ends mid-package and nothing else -- "slow" and "stuck"
are indistinguishable, and each attempt costs a full run to learn nothing. With
this, the next run names the package and shows the call that stalled.

The posix legs get the same bound via timeout(1). Neither has hung, but the
class is not platform-specific and a 6-hour park is worse there too.

The cscript change in the previous commit stays: wscript IS the GUI-mode script
host and does block in a non-interactive session, so both call sites were real
defects. It just was not this one.

* fix(ci): the bound I just added does not exist on macOS

macos-install-test failed in 19 seconds with

    .github/scripts/posix-test.sh: line 313: timeout: command not found
    [FAIL] install failed

once per package. `timeout` is GNU coreutils and is not on a stock macOS
runner; Homebrew installs it as `gtimeout`, which may or may not be present.
My own change, and the fourth POSIX assumption in this repo's history to hold
on Linux and break on macOS.

run_bounded() uses whichever binary exists and, when neither does, does the
same job in shell -- background, poll, TERM, then KILL -- returning 124 on
expiry to match timeout(1). Deliberately not "skip the bound when the tool is
missing": an unbounded run is exactly the failure this was added to prevent, so
"no timeout available" must not quietly become "no timeout needed".

Verified both code paths, forcing the fallback by clearing TIMEOUT_BIN:
expiry 124, success 0, failure 1, on each.

---------

Co-authored-by: sunrisepeak <x.d2learn.org@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants