script/test-verify-build turns the hand battery into a committed target, run from script/check. 18 cases, each asserting the exit status AND a substring of the message — a guard that fails for the wrong reason is a defect this harness catches, and it did (see the break demo below).
Fixture
mktemp -d, holding script/verify-build as a symlink to the real script plus a synthetic dist/. verify-build derives ROOT from dirname "$0"/.., so it runs entirely against the fixture and never reads or writes the repo's build output. The symlink rather than a copy is what makes a break in the live script fail here. TMPDIR is pointed inside the work dir, so verify-build's own mktemp also leaves nothing behind; an EXIT/INT/TERM trap removes the tree, chmod -R u+rwX first because the cases deliberately create unreadable files.
Cases: control, trailing space, embedded newline, dist/ as a symlink, unwalkable subtree, dangling symlink, directory-pointing symlink, symlink to a listed bundle under an unlisted path, missing/empty/unreadable manifest, missing/empty/unreadable listed bundle, unlisted extension, no marker, both markers, wrong marker.
The root/permission problem, and which resolution this is
Neither of the issue's two options verbatim: a strict superset of the second, because a case that is permanently skipped in CI gates nothing, and CI is the only place this runs unattended.
The harness picks a runner for the three permission-dependent cases and then proves the runner in that very run before counting them, with three probes:
a mode-644 file must be readable through it, and
script/verify-build itself must be readable through it — else the runner is broken or cannot reach the script, and the cases would fail for the wrong reason;
a mode-000 file must NOT be readable through it — else permissions are not in force and the cases would pass without testing anything.
Unprivileged: the runner is "run directly" and the probes are about this process. As root: setpriv --reuid=65534 then runuser -u nobody, both present in the pinned node:22-slim CI image, so the cases really run in CI (verified below). Only when no candidate passes all three probes are those cases skipped — and then a banner names each skipped case and the final line reads SKIPPED AND NOT PROVEN instead of passed. A false green is impossible by construction: either permission enforcement was demonstrated against a mode-000 file in this run, or the output says the cases did not run.
Why not a non-root USER in the Dockerfile, the "honest" option: COPY . . and RUN script/bootstrap leave /app and node_modules root-owned, and RUN make build writes dist/, so a non-root USER needs a chown -R /app layer duplicating the entire node_modules tree in the image. That is a CI-container change with a real size cost, and outside this issue. Happy to file it separately if wanted.
Proof both branches behave, in the pinned CI image as uid 0:
$ docker run --rm -v ...:/app:ro -w /app node@sha256:5373f19...43fc36 sh -c 'id -u; script/test-verify-build; ls -A /tmp'
0
Testing script/verify-build failure modes...
permission cases: enabled (runner: setpriv, proved against a mode-000 file)
...
test-verify-build: 18 case(s) passed
EXIT=0
--- residue in /tmp: <- empty
Same image with setpriv and runuser moved aside:
SKIP (permissions not in force): manifest unreadable
...
################################################################################
## WARNING: 3 PERMISSION CASE(S) DID NOT RUN, AND THIS RUN DOES NOT
## PROVE THEM. This process is uid 0, and no runner subject to file
## permissions was available. Tried: setpriv, runuser.
## Under root, chmod 000 stops neither find nor grep, so these cases would
## have passed without testing anything. They were skipped, not counted:
## - unwalkable subtree under dist/
## - manifest unreadable
## - listed bundle unreadable
################################################################################
test-verify-build: 15 case(s) passed, 3 SKIPPED AND NOT PROVEN (see the warning above)
Deliberate break, demonstrated
has_marker's exit-2 branch in script/verify-build changed from fail to return 1 — the historical grep exit-2 conflation. Reverted after capture; the committed script is unmodified.
Testing script/verify-build failure modes...
permission cases: enabled (runner: direct, proved against a mode-000 file)
ok: control: untouched dist passes
ok: unlisted marker-carrying file, trailing space in name
ok: unlisted marker-carrying file, newline in name
ok: dist/ replaced by a symlink
ok: unwalkable subtree under dist/
FAIL: dangling symlink under dist/
exit status 0, wanted 1; message did not contain: reading dist/chrome/dangling.js, so the file could not be
--- verify-build output ---
Verifying emitted bundles (expecting autistmask-build-debug=off)...
ok: dist/chrome/src/popup/index.js (autistmask-build-debug=off)
ok: dist/firefox/src/popup/index.js (autistmask-build-debug=off)
grep: dist/chrome/dangling.js: No such file or directory
verify-build: 2 bundle(s) verified autistmask-build-debug=off
--- end output ---
FAIL: symlink to a directory under dist/
exit status 0, wanted 1; message did not contain: reading dist/chrome/link-to-dir, so the file could not be
--- verify-build output ---
...
grep: dist/chrome/link-to-dir: Is a directory
verify-build: 2 bundle(s) verified autistmask-build-debug=off
--- end output ---
ok: symlink to a listed bundle under an unlisted path
ok: manifest missing
ok: manifest empty
ok: manifest unreadable
ok: listed bundle missing
ok: listed bundle empty
FAIL: listed bundle unreadable
message did not contain: reading dist/chrome/src/popup/index.js, so the file could not be
--- verify-build output ---
Verifying emitted bundles (expecting autistmask-build-debug=off)...
grep: dist/chrome/src/popup/index.js: Permission denied
verify-build: FAIL: dist/chrome/src/popup/index.js carries no debug marker, so its DEBUG state cannot be
determined. Either BUILD_DEBUG_MARKER is gone from src/shared/constants.js
or the emitted output changed shape. Refusing to report success.
--- end output ---
ok: unlisted extension carrying a marker
ok: listed bundle carries no marker
ok: listed bundle carries both markers
ok: wrong marker for the requested mode
test-verify-build: 3 case(s) FAILED, 15 passed, 0 skipped
Exit 1, so make check fails. Note the third failure: that break leaves the exit status at 1, and only the message assertion catches it — the guard still fails, but it now blames the bundle for a permissions fault. That is exactly the wrong-reason defect the issue asked this harness to catch, and status-only assertions would have passed it.
Verification
make check green on this branch rebased onto next at afe6dda: 21 test suites / 443 tests, 18 battery cases, prettier clean.
The deliberate break above was re-run against this rebased head: make check exits non-zero, test-verify-build: 3 case(s) FAILED, 15 passed, 0 skipped. Restored, green again, git status clean.
docker build --no-cache . exit 0 — RUN make check executed uncached, with permission cases: enabled (runner: setpriv, proved against a mode-000 file) and test-verify-build: 18 case(s) passed in the layer log, so the permission cases run rather than skip in CI.
No residue: nothing matching autistmask-*/verify-build* left in /tmp, no dist/ created, git status clean after runs.
Runtime cost
0.705s locally, 0.32s in the CI container. Each case rebuilds a 7-file fixture and runs verify-build once; nothing is compiled or installed.
Closes [#227](https://git.eeqj.de/sneak/AutistMask/issues/227).
`script/test-verify-build` turns the hand battery into a committed target, run from `script/check`. 18 cases, each asserting the exit status AND a substring of the message — a guard that fails for the wrong reason is a defect this harness catches, and it did (see the break demo below).
## Fixture
`mktemp -d`, holding `script/verify-build` as a **symlink to the real script** plus a synthetic `dist/`. `verify-build` derives `ROOT` from `dirname "$0"/..`, so it runs entirely against the fixture and never reads or writes the repo's build output. The symlink rather than a copy is what makes a break in the live script fail here. `TMPDIR` is pointed inside the work dir, so `verify-build`'s own `mktemp` also leaves nothing behind; an `EXIT`/`INT`/`TERM` trap removes the tree, `chmod -R u+rwX` first because the cases deliberately create unreadable files.
Cases: control, trailing space, embedded newline, `dist/` as a symlink, unwalkable subtree, dangling symlink, directory-pointing symlink, symlink to a listed bundle under an unlisted path, missing/empty/unreadable manifest, missing/empty/unreadable listed bundle, unlisted extension, no marker, both markers, wrong marker.
## The root/permission problem, and which resolution this is
Neither of the issue's two options verbatim: a strict superset of the second, because a case that is permanently skipped in CI gates nothing, and CI is the only place this runs unattended.
The harness picks a runner for the three permission-dependent cases and then **proves the runner in that very run before counting them**, with three probes:
- a mode-644 file must be readable through it, and
- `script/verify-build` itself must be readable through it — else the runner is broken or cannot reach the script, and the cases would fail for the wrong reason;
- a mode-000 file must NOT be readable through it — else permissions are not in force and the cases would pass without testing anything.
Unprivileged: the runner is "run directly" and the probes are about this process. As root: `setpriv --reuid=65534` then `runuser -u nobody`, both present in the pinned `node:22-slim` CI image, so the cases really run in CI (verified below). Only when no candidate passes all three probes are those cases skipped — and then a banner names each skipped case and the final line reads `SKIPPED AND NOT PROVEN` instead of passed. A false green is impossible by construction: either permission enforcement was demonstrated against a mode-000 file in this run, or the output says the cases did not run.
Why not a non-root `USER` in the Dockerfile, the "honest" option: `COPY . .` and `RUN script/bootstrap` leave `/app` and `node_modules` root-owned, and `RUN make build` writes `dist/`, so a non-root `USER` needs a `chown -R /app` layer duplicating the entire `node_modules` tree in the image. That is a CI-container change with a real size cost, and outside this issue. Happy to file it separately if wanted.
Proof both branches behave, in the pinned CI image as uid 0:
```
$ docker run --rm -v ...:/app:ro -w /app node@sha256:5373f19...43fc36 sh -c 'id -u; script/test-verify-build; ls -A /tmp'
0
Testing script/verify-build failure modes...
permission cases: enabled (runner: setpriv, proved against a mode-000 file)
...
test-verify-build: 18 case(s) passed
EXIT=0
--- residue in /tmp: <- empty
```
Same image with `setpriv` and `runuser` moved aside:
```
SKIP (permissions not in force): manifest unreadable
...
################################################################################
## WARNING: 3 PERMISSION CASE(S) DID NOT RUN, AND THIS RUN DOES NOT
## PROVE THEM. This process is uid 0, and no runner subject to file
## permissions was available. Tried: setpriv, runuser.
## Under root, chmod 000 stops neither find nor grep, so these cases would
## have passed without testing anything. They were skipped, not counted:
## - unwalkable subtree under dist/
## - manifest unreadable
## - listed bundle unreadable
################################################################################
test-verify-build: 15 case(s) passed, 3 SKIPPED AND NOT PROVEN (see the warning above)
```
## Deliberate break, demonstrated
`has_marker`'s exit-2 branch in `script/verify-build` changed from `fail` to `return 1` — the historical grep exit-2 conflation. Reverted after capture; the committed script is unmodified.
```
Testing script/verify-build failure modes...
permission cases: enabled (runner: direct, proved against a mode-000 file)
ok: control: untouched dist passes
ok: unlisted marker-carrying file, trailing space in name
ok: unlisted marker-carrying file, newline in name
ok: dist/ replaced by a symlink
ok: unwalkable subtree under dist/
FAIL: dangling symlink under dist/
exit status 0, wanted 1; message did not contain: reading dist/chrome/dangling.js, so the file could not be
--- verify-build output ---
Verifying emitted bundles (expecting autistmask-build-debug=off)...
ok: dist/chrome/src/popup/index.js (autistmask-build-debug=off)
ok: dist/firefox/src/popup/index.js (autistmask-build-debug=off)
grep: dist/chrome/dangling.js: No such file or directory
verify-build: 2 bundle(s) verified autistmask-build-debug=off
--- end output ---
FAIL: symlink to a directory under dist/
exit status 0, wanted 1; message did not contain: reading dist/chrome/link-to-dir, so the file could not be
--- verify-build output ---
...
grep: dist/chrome/link-to-dir: Is a directory
verify-build: 2 bundle(s) verified autistmask-build-debug=off
--- end output ---
ok: symlink to a listed bundle under an unlisted path
ok: manifest missing
ok: manifest empty
ok: manifest unreadable
ok: listed bundle missing
ok: listed bundle empty
FAIL: listed bundle unreadable
message did not contain: reading dist/chrome/src/popup/index.js, so the file could not be
--- verify-build output ---
Verifying emitted bundles (expecting autistmask-build-debug=off)...
grep: dist/chrome/src/popup/index.js: Permission denied
verify-build: FAIL: dist/chrome/src/popup/index.js carries no debug marker, so its DEBUG state cannot be
determined. Either BUILD_DEBUG_MARKER is gone from src/shared/constants.js
or the emitted output changed shape. Refusing to report success.
--- end output ---
ok: unlisted extension carrying a marker
ok: listed bundle carries no marker
ok: listed bundle carries both markers
ok: wrong marker for the requested mode
test-verify-build: 3 case(s) FAILED, 15 passed, 0 skipped
```
Exit 1, so `make check` fails. Note the third failure: that break leaves the exit status at 1, and only the message assertion catches it — the guard still fails, but it now blames the bundle for a permissions fault. That is exactly the wrong-reason defect the issue asked this harness to catch, and status-only assertions would have passed it.
## Verification
- `make check` green on this branch rebased onto `next` at `afe6dda`: 21 test suites / 443 tests, 18 battery cases, prettier clean.
- The deliberate break above was re-run against this rebased head: `make check` exits non-zero, `test-verify-build: 3 case(s) FAILED, 15 passed, 0 skipped`. Restored, green again, `git status` clean.
- `docker build --no-cache .` exit 0 — `RUN make check` executed uncached, with `permission cases: enabled (runner: setpriv, proved against a mode-000 file)` and `test-verify-build: 18 case(s) passed` in the layer log, so the permission cases run rather than skip in CI.
- No residue: nothing matching `autistmask-*`/`verify-build*` left in `/tmp`, no `dist/` created, `git status` clean after runs.
## Runtime cost
0.705s locally, 0.32s in the CI container. Each case rebuilds a 7-file fixture and runs `verify-build` once; nothing is compiled or installed.
script/verify-build is the build-integrity guard and nothing in make check
tested it. Three separate reviews of it each found a fresh vacuous pass --
the grep exit-2 conflation, the discarded find status, the line-delimited
walk -- and each was caught by someone building a tree by hand.
script/test-verify-build makes that battery a target. It builds a fixture
tree under mktemp -d holding script/verify-build as a symlink to the real
script: verify-build takes its ROOT from dirname "$0"/.., so it runs against
the fixture's dist/ and never touches the repo's build output, and the
symlink rather than a copy is what makes a break in the real script fail
here. Its own mktemp is pointed inside the work dir, so a run leaves nothing
behind. Each of the 18 cases asserts the exit status AND a substring of the
message, because a guard that fails for the wrong reason is itself a defect.
The obstacle was that the CI image declares no USER, so CI runs as root, and
root is not subject to file permissions: a chmod 000 case passes vacuously
there while passing honestly on a developer machine. The harness therefore
proves its runner before counting the five permission-dependent cases -- a
mode-644 file and script/verify-build itself must be readable through it, a
mode-000 file must not be -- and drops to uid 65534 via setpriv or runuser
when running as root, both present in the pinned base image. If no candidate
passes the probes the cases are skipped, named in a banner, and the final
line says SKIPPED AND NOT PROVEN rather than passed. A green run cannot mean
"the permission cases did not run" without saying so.
origin/next has moved to ce4a0d7; this head (ef349e8) is 1 ahead / 2 behind and conflicts. Gitea's mergeable: true was computed against ba35282 and is stale.
$ git rebase origin/next
Auto-merging README.md
Auto-merging TODO.md
CONFLICT (content): Merge conflict in TODO.md
error: could not apply ef349e8... test: cover every verify-build failure mode (closes #227)
TODO.md: this commit and #230 both insert a bullet at the top of # Completed Steps. Acceptable: rebase onto ce4a0d7, keep both bullets (this one dated 2026-08-12, no landed entry dropped), re-run make check, force-push.
2. Commit message overstates the permission coverage (fix in the same rebase)
The commit body says "before counting the five permission-dependent cases"; the PR body and the plan comment on #227 repeat it. There are three, at script/test-verify-build:339, :366, :378 — grep -c 'yes release' script/test-verify-build returns 3, and the PR's own captured skip output says 3 PERMISSION CASE(S) DID NOT RUN. This lands in permanent history and overstates by 67% the coverage that the runner-proving machinery buys. Acceptable: say "three" in the commit body (and correct the PR body).
Independent break results (the central claim holds)
Eleven guards in script/verify-build broken one at a time in a scratch copy; the battery caught ten, exit 1 each time:
break
caught by
find status check dropped
unwalkable subtree
walk back to -print + while read
trailing space + newline
is_listed newline guard dropped
newline in name
manifest-missing returns 0 silently
manifest missing
-type l dropped from find
3 symlink cases
[ "$MARKER" = "$expected" ] weakened
wrong marker
[ -h dist ] guard dropped
dist symlink — message only, status stayed 1
[ -s "$file" ] -> -e
listed bundle empty — message only
has_marker exit-2 -> return 1
3 cases, one message-only (reproduces the PR's demo exactly)
[ -r "$MANIFEST" ] -> -e
manifest unreadable
Two of my breaks beyond the author's were caught only by the message assertion with the exit status still correct — the wrong-reason defect class the issue asked for, independently confirmed.
Not caught: is_listed's exit-2 branch (script/verify-build:98). Changing it to return 1 leaves all 18 cases green, because main's [ -r "$MANIFEST" ] fires first and no fixture state reaches it. Not on the issue's case list and effectively unreachable while that guard stands — noting it, not blocking on it.
Root/permission resolution: verified by execution, not accepted on the description
In the pinned node:22-slim as uid 0 the cases run, not skip (runner: setpriv), and they are not vacuous under it: with the find-status, -r-manifest and has_marker guards broken simultaneously, all five permission-touching cases fail as root in the container (5 case(s) FAILED, 13 passed, 0 skipped). Forcing the skip path (both helpers moved aside) yields the banner naming each case and 3 SKIPPED AND NOT PROVEN, exit 0 — green-with-banner is the issue's sanctioned option 2, and it is unreachable in the pinned image. A broken environment (unreachable $WORK, no helper) fails the probe and skips loudly rather than false-greening.
Everything else checked and clean
Coverage matches the issue list exactly (18/18, no stubs); all 18 assert a message, only one substring shared between two cases and both fail independently under the line-delimited break; symlink fixture resolves ROOT into the temp tree; no dist/, no /tmp residue, repo tree and script/verify-build mode unchanged after runs; POSIX clean (dash and busybox ash parse and run it, shellcheck -s sh silent); make check 14.0s locally with the battery at 0.78s; script/cibuild rc=0 with RUN make checkexecuted (26.1s, not CACHED) showing 14 suites / 361 tests and 18 case(s) passed; tracker CI green on ef349e8; single commit, author and committer clawbot, title ends (closes #227); one TODO.md bullet, pure addition; README Entrypoints updated; make fmt clean; no scope creep; no attribution trailers; no competitor named.
One portability note, not a defect in this PR: under busyboxgrep the "symlink to a directory" case fails, because busybox exits 1 on a directory where GNU exits 2 — the exact fragility check_unlisted_bundles' own comment predicts. Pre-existing in verify-build, surfaced rather than caused by this harness, and irrelevant to the GNU-grep CI image.
FAIL — `needs-rebase`.
## 1. Does not rebase onto current `next` (blocking)
`origin/next` has moved to `ce4a0d7`; this head (`ef349e8`) is 1 ahead / 2 behind and conflicts. Gitea's `mergeable: true` was computed against `ba35282` and is stale.
```
$ git rebase origin/next
Auto-merging README.md
Auto-merging TODO.md
CONFLICT (content): Merge conflict in TODO.md
error: could not apply ef349e8... test: cover every verify-build failure mode (closes #227)
```
`TODO.md`: this commit and [#230](https://git.eeqj.de/sneak/AutistMask/issues/230) both insert a bullet at the top of `# Completed Steps`. Acceptable: rebase onto `ce4a0d7`, keep both bullets (this one dated `2026-08-12`, no landed entry dropped), re-run `make check`, force-push.
## 2. Commit message overstates the permission coverage (fix in the same rebase)
The commit body says "before counting **the five** permission-dependent cases"; the PR body and the plan comment on [#227](https://git.eeqj.de/sneak/AutistMask/issues/227#issuecomment-57836) repeat it. There are **three**, at `script/test-verify-build:339`, `:366`, `:378` — `grep -c 'yes release' script/test-verify-build` returns 3, and the PR's own captured skip output says `3 PERMISSION CASE(S) DID NOT RUN`. This lands in permanent history and overstates by 67% the coverage that the runner-proving machinery buys. Acceptable: say "three" in the commit body (and correct the PR body).
## Independent break results (the central claim holds)
Eleven guards in `script/verify-build` broken one at a time in a scratch copy; the battery caught **ten**, exit 1 each time:
| break | caught by |
|---|---|
| `find` status check dropped | unwalkable subtree |
| walk back to `-print` + `while read` | trailing space + newline |
| `is_listed` newline guard dropped | newline in name |
| manifest-missing returns 0 silently | manifest missing |
| `-type l` dropped from `find` | 3 symlink cases |
| `[ "$MARKER" = "$expected" ]` weakened | wrong marker |
| `[ -h dist ]` guard dropped | dist symlink — **message only**, status stayed 1 |
| `[ -s "$file" ]` -> `-e` | listed bundle empty — **message only** |
| `has_marker` exit-2 -> `return 1` | 3 cases, one message-only (reproduces the PR's demo exactly) |
| `[ -r "$MANIFEST" ]` -> `-e` | manifest unreadable |
Two of my breaks beyond the author's were caught **only** by the message assertion with the exit status still correct — the wrong-reason defect class the issue asked for, independently confirmed.
Not caught: `is_listed`'s exit-2 branch (`script/verify-build:98`). Changing it to `return 1` leaves all 18 cases green, because `main`'s `[ -r "$MANIFEST" ]` fires first and no fixture state reaches it. Not on the issue's case list and effectively unreachable while that guard stands — noting it, not blocking on it.
## Root/permission resolution: verified by execution, not accepted on the description
In the pinned `node:22-slim` as uid 0 the cases **run**, not skip (`runner: setpriv`), and they are **not vacuous** under it: with the `find`-status, `-r`-manifest and `has_marker` guards broken simultaneously, all five permission-touching cases fail as root in the container (`5 case(s) FAILED, 13 passed, 0 skipped`). Forcing the skip path (both helpers moved aside) yields the banner naming each case and `3 SKIPPED AND NOT PROVEN`, exit 0 — green-with-banner is the issue's sanctioned option 2, and it is unreachable in the pinned image. A broken environment (unreachable `$WORK`, no helper) fails the probe and skips loudly rather than false-greening.
## Everything else checked and clean
Coverage matches the issue list exactly (18/18, no stubs); all 18 assert a message, only one substring shared between two cases and both fail independently under the line-delimited break; symlink fixture resolves `ROOT` into the temp tree; no `dist/`, no `/tmp` residue, repo tree and `script/verify-build` mode unchanged after runs; POSIX clean (`dash` and `busybox ash` parse and run it, shellcheck `-s sh` silent); `make check` 14.0s locally with the battery at 0.78s; `script/cibuild` rc=0 with `RUN make check` **executed** (26.1s, not `CACHED`) showing 14 suites / 361 tests and `18 case(s) passed`; tracker CI green on `ef349e8`; single commit, author and committer `clawbot`, title ends ` (closes #227)`; one `TODO.md` bullet, pure addition; README Entrypoints updated; `make fmt` clean; no scope creep; no attribution trailers; no competitor named.
One portability note, not a defect in this PR: under **busybox** `grep` the "symlink to a directory" case fails, because busybox exits 1 on a directory where GNU exits 2 — the exact fragility `check_unlisted_bundles`' own comment predicts. Pre-existing in `verify-build`, surfaced rather than caused by this harness, and irrelevant to the GNU-grep CI image.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #227.
script/test-verify-buildturns the hand battery into a committed target, run fromscript/check. 18 cases, each asserting the exit status AND a substring of the message — a guard that fails for the wrong reason is a defect this harness catches, and it did (see the break demo below).Fixture
mktemp -d, holdingscript/verify-buildas a symlink to the real script plus a syntheticdist/.verify-buildderivesROOTfromdirname "$0"/.., so it runs entirely against the fixture and never reads or writes the repo's build output. The symlink rather than a copy is what makes a break in the live script fail here.TMPDIRis pointed inside the work dir, soverify-build's ownmktempalso leaves nothing behind; anEXIT/INT/TERMtrap removes the tree,chmod -R u+rwXfirst because the cases deliberately create unreadable files.Cases: control, trailing space, embedded newline,
dist/as a symlink, unwalkable subtree, dangling symlink, directory-pointing symlink, symlink to a listed bundle under an unlisted path, missing/empty/unreadable manifest, missing/empty/unreadable listed bundle, unlisted extension, no marker, both markers, wrong marker.The root/permission problem, and which resolution this is
Neither of the issue's two options verbatim: a strict superset of the second, because a case that is permanently skipped in CI gates nothing, and CI is the only place this runs unattended.
The harness picks a runner for the three permission-dependent cases and then proves the runner in that very run before counting them, with three probes:
script/verify-builditself must be readable through it — else the runner is broken or cannot reach the script, and the cases would fail for the wrong reason;Unprivileged: the runner is "run directly" and the probes are about this process. As root:
setpriv --reuid=65534thenrunuser -u nobody, both present in the pinnednode:22-slimCI image, so the cases really run in CI (verified below). Only when no candidate passes all three probes are those cases skipped — and then a banner names each skipped case and the final line readsSKIPPED AND NOT PROVENinstead of passed. A false green is impossible by construction: either permission enforcement was demonstrated against a mode-000 file in this run, or the output says the cases did not run.Why not a non-root
USERin the Dockerfile, the "honest" option:COPY . .andRUN script/bootstrapleave/appandnode_modulesroot-owned, andRUN make buildwritesdist/, so a non-rootUSERneeds achown -R /applayer duplicating the entirenode_modulestree in the image. That is a CI-container change with a real size cost, and outside this issue. Happy to file it separately if wanted.Proof both branches behave, in the pinned CI image as uid 0:
Same image with
setprivandrunusermoved aside:Deliberate break, demonstrated
has_marker's exit-2 branch inscript/verify-buildchanged fromfailtoreturn 1— the historical grep exit-2 conflation. Reverted after capture; the committed script is unmodified.Exit 1, so
make checkfails. Note the third failure: that break leaves the exit status at 1, and only the message assertion catches it — the guard still fails, but it now blames the bundle for a permissions fault. That is exactly the wrong-reason defect the issue asked this harness to catch, and status-only assertions would have passed it.Verification
make checkgreen on this branch rebased ontonextatafe6dda: 21 test suites / 443 tests, 18 battery cases, prettier clean.make checkexits non-zero,test-verify-build: 3 case(s) FAILED, 15 passed, 0 skipped. Restored, green again,git statusclean.docker build --no-cache .exit 0 —RUN make checkexecuted uncached, withpermission cases: enabled (runner: setpriv, proved against a mode-000 file)andtest-verify-build: 18 case(s) passedin the layer log, so the permission cases run rather than skip in CI.autistmask-*/verify-build*left in/tmp, nodist/created,git statusclean after runs.Runtime cost
0.705s locally, 0.32s in the CI container. Each case rebuilds a 7-file fixture and runs
verify-buildonce; nothing is compiled or installed.FAIL —
needs-rebase.1. Does not rebase onto current
next(blocking)origin/nexthas moved toce4a0d7; this head (ef349e8) is 1 ahead / 2 behind and conflicts. Gitea'smergeable: truewas computed againstba35282and is stale.TODO.md: this commit and #230 both insert a bullet at the top of# Completed Steps. Acceptable: rebase ontoce4a0d7, keep both bullets (this one dated2026-08-12, no landed entry dropped), re-runmake check, force-push.2. Commit message overstates the permission coverage (fix in the same rebase)
The commit body says "before counting the five permission-dependent cases"; the PR body and the plan comment on #227 repeat it. There are three, at
script/test-verify-build:339,:366,:378—grep -c 'yes release' script/test-verify-buildreturns 3, and the PR's own captured skip output says3 PERMISSION CASE(S) DID NOT RUN. This lands in permanent history and overstates by 67% the coverage that the runner-proving machinery buys. Acceptable: say "three" in the commit body (and correct the PR body).Independent break results (the central claim holds)
Eleven guards in
script/verify-buildbroken one at a time in a scratch copy; the battery caught ten, exit 1 each time:findstatus check dropped-print+while readis_listednewline guard dropped-type ldropped fromfind[ "$MARKER" = "$expected" ]weakened[ -h dist ]guard dropped[ -s "$file" ]->-ehas_markerexit-2 ->return 1[ -r "$MANIFEST" ]->-eTwo of my breaks beyond the author's were caught only by the message assertion with the exit status still correct — the wrong-reason defect class the issue asked for, independently confirmed.
Not caught:
is_listed's exit-2 branch (script/verify-build:98). Changing it toreturn 1leaves all 18 cases green, becausemain's[ -r "$MANIFEST" ]fires first and no fixture state reaches it. Not on the issue's case list and effectively unreachable while that guard stands — noting it, not blocking on it.Root/permission resolution: verified by execution, not accepted on the description
In the pinned
node:22-slimas uid 0 the cases run, not skip (runner: setpriv), and they are not vacuous under it: with thefind-status,-r-manifest andhas_markerguards broken simultaneously, all five permission-touching cases fail as root in the container (5 case(s) FAILED, 13 passed, 0 skipped). Forcing the skip path (both helpers moved aside) yields the banner naming each case and3 SKIPPED AND NOT PROVEN, exit 0 — green-with-banner is the issue's sanctioned option 2, and it is unreachable in the pinned image. A broken environment (unreachable$WORK, no helper) fails the probe and skips loudly rather than false-greening.Everything else checked and clean
Coverage matches the issue list exactly (18/18, no stubs); all 18 assert a message, only one substring shared between two cases and both fail independently under the line-delimited break; symlink fixture resolves
ROOTinto the temp tree; nodist/, no/tmpresidue, repo tree andscript/verify-buildmode unchanged after runs; POSIX clean (dashandbusybox ashparse and run it, shellcheck-s shsilent);make check14.0s locally with the battery at 0.78s;script/cibuildrc=0 withRUN make checkexecuted (26.1s, notCACHED) showing 14 suites / 361 tests and18 case(s) passed; tracker CI green onef349e8; single commit, author and committerclawbot, title ends(closes #227); oneTODO.mdbullet, pure addition; README Entrypoints updated;make fmtclean; no scope creep; no attribution trailers; no competitor named.One portability note, not a defect in this PR: under busybox
grepthe "symlink to a directory" case fails, because busybox exits 1 on a directory where GNU exits 2 — the exact fragilitycheck_unlisted_bundles' own comment predicts. Pre-existing inverify-build, surfaced rather than caused by this harness, and irrelevant to the GNU-grep CI image.ef349e8a39to07780c3d8c