scripts/ci-local.sh prints ci-local: PASS after running a test that skipped the assertion CI runs it for, and says nothing about the difference.
What happens
CI runs one release test with elevated privileges, and only one:
.github/workflows/ci.yml
- name: Check bounded actions and removal of shell grants
run: sudo -n bash tests/release/action-steps.test.sh
run_shell_tests discovers the same file by glob and runs every test the same way:
run_step "hygiene: ${test#"$repo_root/"}" bash "$test"
So locally it runs as whoever you are. tests/release/action-steps.test.sh carries
@unittest.skipUnless(hasattr(os, 'geteuid') and os.geteuid() == 0,
'real credential-drop test requires a root test subprocess')
def test_real_drop_cannot_regain_root_or_write_through_a_symlink(self):
and that is the case the sudo -n exists for. Unprivileged, it skips:
test_real_drop_cannot_regain_root_or_write_through_a_symlink ... skipped
Ran 11 tests in 0.018s
OK (skipped=1)
run_step sees exit 0, records a pass, and the summary ends:
=========================================
ci-local: PASS
The one assertion that proves the helper cannot regain root or write through a symlink never executed, and nothing in the output says so.
Why this shape and not another
required_skips already exists for exactly this problem, and is used for exactly one thing:
$ grep -n 'required_skips' scripts/ci-local.sh
79:required_skips=()
351: required_skips+=(postgres-contract)
375: required_skips+=(postgres-contract)
444:if ((${#required_skips[@]} > 0)); then
445: printf 'WARNING: REQUIRED CI check(s) did not run: %s\n' "${required_skips[*]}"
(Line numbers recounted at b2c823e6 on 2026-09-16; they were 350, 374, 443 and
444 when I filed this. Everything else below still lands.)
A missing Postgres downgrades the run to INCOMPLETE. A missing root does not, and the reason is that the sudo step arrived after that machinery did.
The part that will not stay one test
Today the class has exactly one member. sudo -n bash tests/ matches one line in the workflows, and one test file carries a root condition. Nothing detects the next one. A second privileged step added to ci.yml gets the same silent gap on the day it lands, and the local run keeps saying PASS.
tests/release/ci-local.test.sh compares the discovered set against the workflow set by path and asserts they match. It does not compare how each side invokes them, which is where the divergence lives.
Suggested shape
Read the invocation out of the workflows rather than hardcoding a list, the way discovery already reads the file set:
- extract
tests/(release|e2e)/*.test.sh from .github/workflows/*.yml together with whether the step runs it under sudo
- when
ci-local runs one of those without being root, add it to required_skips and let the existing summary do the rest
- extend
tests/release/ci-local.test.sh so a new privileged step in a workflow fails the guard until ci-local knows about it
The point is that the guard is derived from the workflow, so it cannot go stale the way a hardcoded list does.
Reproducing
bash tests/release/action-steps.test.sh; echo "rc=$?"
# OK (skipped=1), rc=0
sudo -n bash tests/release/action-steps.test.sh 2>&1 | tail -3
# Ran 11 tests, no skip
Tests first
Break what the guard protects. Add a second privileged step to a fixture
workflow, sudo -n bash tests/release/<something>.test.sh, and require
ci-local run as a non-root user to name it in required_skips and finish
INCOMPLETE rather than PASS. Write it before the fix and watch it print
ci-local: PASS, which is the defect. The existing Postgres path is the worked
example of the behaviour you want:
$ grep -n 'required_skips' scripts/ci-local.sh
79:required_skips=()
351: required_skips+=(postgres-contract)
375: required_skips+=(postgres-contract)
444:if ((${#required_skips[@]} > 0)); then
445: printf 'WARNING: REQUIRED CI check(s) did not run: %s\n' "${required_skips[*]}"
Break the guard's own input. This check has one specific way of being
useless: the workflow scan finds no privileged steps and the run reports no
skips, which is indistinguishable from a clean pass. Assert the count. A fixture
workflow set containing two sudo -n bash tests/... steps must yield two
detections, so a regex that stops matching (sudo --non-interactive, a step
that sets sudo through a shell variable, a run: block spanning lines) turns
red rather than quietly reporting nothing to skip.
Extend tests/release/ci-local.test.sh in the same change, so a privileged step
added to a workflow tomorrow fails the guard until ci-local knows about it.
Today it compares the two sides by path and not by invocation, which is why this
gap exists.
Difficulty
medium. Bash and two workflow readers, no Rust and no VM. The work is deriving
the privileged set from .github/workflows/*.yml rather than hardcoding the one
file that carries it today, and proving the derivation fails loudly when it
selects nothing.
Getting started
CONTRIBUTING.md
has the build and test commands. The two that matter here are
bash scripts/ci-local.sh and bash tests/release/ci-local.test.sh. You do not
need a root password to work on this; you need to run ci-local without one
and watch what it says. No CLA and no copyright waiver. The project is MIT.
scripts/ci-local.shprintsci-local: PASSafter running a test that skipped the assertion CI runs it for, and says nothing about the difference.What happens
CI runs one release test with elevated privileges, and only one:
run_shell_testsdiscovers the same file by glob and runs every test the same way:So locally it runs as whoever you are.
tests/release/action-steps.test.shcarriesand that is the case the
sudo -nexists for. Unprivileged, it skips:run_stepsees exit 0, records a pass, and the summary ends:The one assertion that proves the helper cannot regain root or write through a symlink never executed, and nothing in the output says so.
Why this shape and not another
required_skipsalready exists for exactly this problem, and is used for exactly one thing:(Line numbers recounted at
b2c823e6on 2026-09-16; they were 350, 374, 443 and444 when I filed this. Everything else below still lands.)
A missing Postgres downgrades the run to
INCOMPLETE. A missing root does not, and the reason is that the sudo step arrived after that machinery did.The part that will not stay one test
Today the class has exactly one member.
sudo -n bash tests/matches one line in the workflows, and one test file carries a root condition. Nothing detects the next one. A second privileged step added toci.ymlgets the same silent gap on the day it lands, and the local run keeps saying PASS.tests/release/ci-local.test.shcompares the discovered set against the workflow set by path and asserts they match. It does not compare how each side invokes them, which is where the divergence lives.Suggested shape
Read the invocation out of the workflows rather than hardcoding a list, the way discovery already reads the file set:
tests/(release|e2e)/*.test.shfrom.github/workflows/*.ymltogether with whether the step runs it undersudoci-localruns one of those without being root, add it torequired_skipsand let the existing summary do the resttests/release/ci-local.test.shso a new privileged step in a workflow fails the guard untilci-localknows about itThe point is that the guard is derived from the workflow, so it cannot go stale the way a hardcoded list does.
Reproducing
Tests first
Break what the guard protects. Add a second privileged step to a fixture
workflow,
sudo -n bash tests/release/<something>.test.sh, and requireci-localrun as a non-root user to name it inrequired_skipsand finishINCOMPLETErather thanPASS. Write it before the fix and watch it printci-local: PASS, which is the defect. The existing Postgres path is the workedexample of the behaviour you want:
Break the guard's own input. This check has one specific way of being
useless: the workflow scan finds no privileged steps and the run reports no
skips, which is indistinguishable from a clean pass. Assert the count. A fixture
workflow set containing two
sudo -n bash tests/...steps must yield twodetections, so a regex that stops matching (
sudo --non-interactive, a stepthat sets
sudothrough a shell variable, arun:block spanning lines) turnsred rather than quietly reporting nothing to skip.
Extend
tests/release/ci-local.test.shin the same change, so a privileged stepadded to a workflow tomorrow fails the guard until
ci-localknows about it.Today it compares the two sides by path and not by invocation, which is why this
gap exists.
Difficulty
medium. Bash and two workflow readers, no Rust and no VM. The work is derivingthe privileged set from
.github/workflows/*.ymlrather than hardcoding the onefile that carries it today, and proving the derivation fails loudly when it
selects nothing.
Getting started
CONTRIBUTING.md
has the build and test commands. The two that matter here are
bash scripts/ci-local.shandbash tests/release/ci-local.test.sh. You do notneed a root password to work on this; you need to run
ci-localwithout oneand watch what it says. No CLA and no copyright waiver. The project is MIT.