Fix piped installer under nounset - #568
Conversation
There was a problem hiding this comment.
Pull request overview
Fixes the Bash installer’s “source guard” so curl … | bash works under set -u by tolerating an unset BASH_SOURCE[0], while still preventing main from running when the script is sourced (e.g., by tests).
Tip
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.
Changes:
- Update
scripts/install.shto safely compare${BASH_SOURCE[0]}against$0even whenBASH_SOURCE[0]is unset during stdin execution. - Add a Bats regression test that pipes the real installer into Bash and asserts it fails at the curl prerequisite (without downloading/installing) rather than exiting due to
nounset.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| scripts/install.sh | Makes the source-vs-exec guard safe under set -u for piped stdin execution. |
| e2e/installer.bats | Adds a regression test that simulates curl … | bash and asserts the installer reaches main without a BASH_SOURCE unbound error. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
All reported issues were addressed across 2 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
e2e/installer.bats:72
- The comment says "Keep PATH empty", but the test actually sets PATH to "$BATS_TEST_TMPDIR". This is effectively a minimal/isolated PATH, but the wording is misleading and makes it harder to understand why this is safe (and why "$BASH" is invoked via an absolute path).
# Keep PATH empty so main stops at its curl prerequisite without downloading
# anything or modifying the test environment.
|
Thanks @pradhankukiran! Taking a look. |
jeremy
left a comment
There was a problem hiding this comment.
Verified locally: old guard fails piped under nounset, new guard reaches main; bats installer suite passes; confirmed under stock bash 3.2.57. Thanks for the fix.
* Add post-deploy installer smoke canary The install endpoint serves main, so merging a change to scripts/install.sh deploys it immediately. #558 shipped a sourcing guard that broke the documented `curl | bash` path under `set -u`; the bats suite now gates that regression pre-merge (#568), but nothing checked the published installer post-merge. Add .github/workflows/installer-smoke.yml: nightly + workflow_dispatch + push-to-main (scripts/install.sh) runs of the canonical Quick Start command against the published endpoint, cache-busted with the commit SHA since github.com/ghraw serves max-age=300. Two legs: Linux (modern bash) and macOS via stock /bin/bash, which asserts exactly Bash 3.2 through the same interpreter so the coverage can't silently degrade. Installs into a scratch HOME with explicit BASECAMP_BIN_DIR and asserts the deterministic installed path. Also two installer-scoped comment fixes from the incident review: correct the "Keep PATH empty" description in e2e/installer.bats (PATH is a binary-free tmpdir, not empty), and document why the guard's `:-$0` fallback is required so a future cleanup doesn't re-break piping. Refs #558, #568 * Set cancel-in-progress explicitly to match workflow convention
* ci: harden installer canary and add pre-merge Bash 3.2 gate Canary (installer-smoke.yml): - Trigger on scripts/install.ps1 pushes too — the raw URL serves main, so merge == deploy for the PowerShell installer as well. - New Windows job running the documented irm | iex shape under both Windows PowerShell 5.1 and pwsh 7 (matrix), against scratch USERPROFILE/BASECAMP_BIN_DIR under RUNNER_TEMP. - New notify job: on any leg's failure, file or update an "Installer canary failure" issue (exact-title dedupe, best-effort gh calls, ::error:: annotation). Skipped entirely on green runs. Pre-merge (test.yml): new installer-bash32 job on macos-latest — plain steps, not bats (bats 1.11 execs `env bash` at every layer, so the interpreter is not pinnable through it). Asserts /bin/bash is exactly 3.2, syntax-checks install.sh, and runs it piped with curl off PATH, asserting the guard's clean failure message and the absence of unbound-variable errors (the #558 regression class). Path-filtered to installer files plus test.yml itself so the job proves itself on the PR that introduces or edits it; skipped filters still report success, so the job is safe to mark required. Refs #558 #568 #569 * TEMP: notify drill (revert me) * Revert "TEMP: notify drill (revert me)" This reverts commit 37cf5f7. * ci: run a full offline install under Bash 3.2, not just the curl guard The piped-execution step stops at the curl prerequisite, and `bash -n` only checks grammar — neither catches Bash-4-only constructs in the download, checksum, extract, or post-install code. Add a step that runs one complete install path under real /bin/bash 3.2 using a fixture curl serving a local release (stub binary, real sha256 checksums) with PATH restricted to fixture + system dirs, then asserts the installed stub runs. Review feedback on #575. * ci: grant the 3.2 gate pull-requests read for paths-filter dorny/paths-filter enumerates changed PR files via the REST API and documents a pull-requests: read requirement; the job-level permissions block zeroes everything unlisted. Review feedback on #575.
Every CLI command runs root PersistentPreRunE -> appctx.NewApp -> auth.NewManager -> auth.NewStore, and credstore.NewStore eagerly probes OS keyring availability with a write. On macOS that probe shells out to an uncancellable `security` child; a locked keychain with no TTY or GUI blocks it forever — on *every* command, including credential-free ones like `setup agents` and `skill install`. That is how the installer hung headless (#568, canary discovery #569/#571 -> #574 drill lineage). Go fix (reaches users at the next release): Store now records the fallback dir at construction and builds the credstore lazily behind sync.Once on first credential operation. Credential-free commands never touch the keyring; credential-touching commands probe on first use exactly as before, with the documented BASECAMP_NO_KEYRING=1 escape hatch. No timeout was added deliberately: an abandoned goroutine cannot kill the hung `security` child, so a timeout would hide the hang while keeping the leak. Bounded fallback needs cancellation support upstream in credstore/go-keyring first. Installer belt (deploys on merge, covers released binaries <= v0.7.2 which still probe eagerly): post_install_setup in install.sh prefixes each best-effort child with per-command BASECAMP_NO_KEYRING=1; Invoke-PostInstallSetup in install.ps1 sets it for the duration of setup and restores the caller's value on exit. These children never touch credentials — the var only skips the old binaries' startup probe. Tests: keyring_test.go pins constructor laziness through an injectable credstore seam. installer.bats gains belt tests that explicitly unset the suite-wide BASECAMP_NO_KEYRING and assert both directions (real calls carry it, the sh capability probe does not), plus a pwsh test that extracts Invoke-PostInstallSetup from install.ps1's AST (no test hooks in the production script), runs it against a logging stub, and asserts set + restore; it fails closed when pwsh is missing in CI. The canary's workflow-level BASECAMP_NO_KEYRING is removed with no step-scoped replacement: the macOS leg now genuinely exercises the deployed belt (a missing belt = hung security child = timeout = notify), and the direct `basecamp version` asserts skip PersistentPreRunE entirely. Refs #558 #568 #569
* Defer keyring probe to first credential use; belt the installers Every CLI command runs root PersistentPreRunE -> appctx.NewApp -> auth.NewManager -> auth.NewStore, and credstore.NewStore eagerly probes OS keyring availability with a write. On macOS that probe shells out to an uncancellable `security` child; a locked keychain with no TTY or GUI blocks it forever — on *every* command, including credential-free ones like `setup agents` and `skill install`. That is how the installer hung headless (#568, canary discovery #569/#571 -> #574 drill lineage). Go fix (reaches users at the next release): Store now records the fallback dir at construction and builds the credstore lazily behind sync.Once on first credential operation. Credential-free commands never touch the keyring; credential-touching commands probe on first use exactly as before, with the documented BASECAMP_NO_KEYRING=1 escape hatch. No timeout was added deliberately: an abandoned goroutine cannot kill the hung `security` child, so a timeout would hide the hang while keeping the leak. Bounded fallback needs cancellation support upstream in credstore/go-keyring first. Installer belt (deploys on merge, covers released binaries <= v0.7.2 which still probe eagerly): post_install_setup in install.sh prefixes each best-effort child with per-command BASECAMP_NO_KEYRING=1; Invoke-PostInstallSetup in install.ps1 sets it for the duration of setup and restores the caller's value on exit. These children never touch credentials — the var only skips the old binaries' startup probe. Tests: keyring_test.go pins constructor laziness through an injectable credstore seam. installer.bats gains belt tests that explicitly unset the suite-wide BASECAMP_NO_KEYRING and assert both directions (real calls carry it, the sh capability probe does not), plus a pwsh test that extracts Invoke-PostInstallSetup from install.ps1's AST (no test hooks in the production script), runs it against a logging stub, and asserts set + restore; it fails closed when pwsh is missing in CI. The canary's workflow-level BASECAMP_NO_KEYRING is removed with no step-scoped replacement: the macOS leg now genuinely exercises the deployed belt (a missing belt = hung security child = timeout = notify), and the direct `basecamp version` asserts skip PersistentPreRunE entirely. Refs #558 #568 #569 * test(installer): pin restore of a caller-set BASECAMP_NO_KEYRING The pwsh belt test proved restore-to-unset only; a regression that overwrites an existing caller value would have passed. Second driver pass sets the var to 0 before Invoke-PostInstallSetup and asserts it survives. Review feedback on #578.
…56) * credstore: forced file-only construction and a bounded keyring probe NewStore probes keyring availability with keyring.Set, which on darwin execs /usr/bin/security -i and Wait()s with no cancellation path. On a locked keychain with no TTY or GUI the child blocks forever and cannot be reclaimed from outside go-keyring, so every credential-touching caller hangs unbounded (basecamp/basecamp-cli#568). Two additions to StoreOptions: * ForceFile forces file-backed storage with no keyring probe — the programmatic equivalent of DisableEnvVar, and the fallback target a caller reaches for after a probe timeout. Like DisableEnvVar, an explicit opt-out carries no fallback warning. * ProbeTimeout bounds the availability probe. Zero keeps today's unbounded probe. When set, the darwin probe mirrors go-keyring's Set (security -i fed add-generic-password over stdin, same escaping and base64 password encoding) via exec.CommandContext, so expiry kills the child instead of leaking it. Non-darwin backends run in-process with no child to reclaim, so the bound there is a plain goroutine timeout. A timed-out probe falls back to file storage with the same warning as a failed probe. Fixes #55 * credstore: document non-positive ProbeTimeout as unbounded The probe treats any non-positive timeout as unbounded (timeout <= 0), but the ProbeTimeout and probe doc comments claimed only zero did. Align both comments with the behavior. * credstore: run probe cleanup under its own budget A successful add may consume the probe deadline; the delete then ran under the exhausted probe context, was skipped or killed, and the probe still returned nil — leaving a stray __probe_* entry in the keychain. Cleanup now gets a fresh bounded context (probeCleanupTimeout) and remains best-effort: the add already proved availability, so a failed delete must not fail the probe. afterProbeAdd is a test seam pinning cleanup's independence from the probe context. * credstore: quote stub paths and bound the PID watcher The generated security stubs embedded pid/args file paths into shell source unquoted, so a space-containing TMPDIR broke the redirections and could hang the kill test's PID watcher indefinitely. Stub paths are now shell-quoted and every stub lives in a dir-with-space so the quoting is pinned on all machines; the watcher observes a deadline and fails fast on fixture breakage. Adds the cleanup-survives-expiry regression for the probe fix. * credstore: fall back to PID+time probe key on rand failure probeKeyName ignored rand.Read errors: a failed or short read yields a predictable (zeroed) key, raising the odds of colliding with an existing entry — which cleanup would then delete. The fallback keys on PID + wall clock, unique enough for a transient probe entry, behind a randRead seam so the failure path is testable. * credstore: run probe cleanup asynchronously A slow cleanup delete under its fresh five-second budget could stretch NewStore past the caller's configured ProbeTimeout, contradicting the documented guarantee that ProbeTimeout bounds the probe. The best-effort delete now runs in a fire-and-forget goroutine: probe latency stays within ProbeTimeout, cleanup keeps its own budget, and the securityPath value is captured synchronously so the goroutine never races the test seam. * credstore: treat short rand reads like rand failures probeKeyName's comment promised protection against failed or short reads but the code only checked the error. A short read now takes the same PID + time fallback, pinned by TestProbeKeyNameShortRead. * credstore: keep probe cleanup joined; document the bounded tail Cleanup returns to synchronous execution under its own budget: Go does not wait for goroutines at process exit, so the detached cleanup could leak one __probe_* entry per short-lived CLI invocation — the primary consumer shape. The honest contract lives in the ProbeTimeout docs now: worst-case construction is ProbeTimeout plus the five-second cleanup bound, typically milliseconds since cleanup only runs when the keyring just proved responsive. Leaking keychain state was judged worse than a theoretical latency tail. Also from the same review round: DisableEnvVar/ForceFile docs now say explicitly that the *named environment variable* being non-empty is what forces file mode; the fallback probe key gains a per-process atomic sequence (UnixNano can repeat on coarse clocks), and the fallback-shape regexes cover the new __probe_<pid>_<nanos>_<seq> format. * credstore: align expiry-regression comment with the joined cleanup Leftover from the asynchronous interlude: the comment claimed detached cleanup and a hard ProbeTimeout-only bound, contradicting the documented additive bound the code now implements. * credstore: use a deterministic probe key so leaks self-heal A timed-out non-darwin probe abandons its goroutine; if the blocked Set later succeeds and the process exits before Delete, the probe entry leaks — and go-keyring has no list API, so under a random name the leftover is permanently unfindable. A fixed, reserved __probe__ name makes the leak self-healing: the next probe's Set overwrites it and its Delete removes it. Concurrent probes sharing the name are harmless — Set results are unaffected and the losing Delete's failure is already ignored. Supersedes the random-key machinery (rand fallback, PID+time key, per-process sequence) and its tests. * credstore: isolate the probe in its own service namespace A private constant does not enforce a name reservation: a service that already held an account named __probe__ would have its password overwritten by the probe's Set and removed by its Delete. The probe now operates in serviceName + ".probe", so it cannot touch any credential in the caller's real service, whatever its name — while keeping the deterministic key's self-healing property within the probe namespace. Also from this round: ProbeTimeout docs scope the additive cleanup bound to darwin (elsewhere cleanup is part of the probe itself), and stale __probe_* wording no longer implies the removed randomized scheme. * credstore: reserve a declared credstore.probe namespace A bare .probe suffix on the caller's service is an ordinary global keyring name, not a reservation. The probe now writes under credstore.probe.<ServiceName>, and the ProbeTimeout docs publicly declare that namespace as reserved by this package — a colliding consumer would have to deliberately adopt the declared namespace, at which point the deterministic key's self-healing semantics are exactly what they'd share. * credstore: pin the probe leak-containment contract cross-platform The deterministic-name containment for abandoned probes (a timed-out non-darwin probe whose blocked Set completes after process exit leaves at most one known entry, overwritten and removed by the next probe) was only exercised by darwin-local tests — invisible to ubuntu CI. A pure derivation helper and a platform-independent test now pin the reserved service and account names; changing either would orphan entries written by earlier releases. * credstore: document that bounded darwin probes sit below MockInit The child-process probe exists precisely to operate beneath go-keyring's uncancellable exec layer, so it cannot observe the mocked provider — go-keyring exports no way to detect MockInit. State the limitation on ProbeTimeout with the test guidance: mocked-keyring tests use a zero timeout (probeDirect honors the mock) or ForceFile.
Stderr alone misclassified routine interactive use: `2>auth.log` from a terminal would have bounded the probe and, on a >10s unlock answer, silently degraded to plaintext file storage with the warning hidden in the redirected log. Headless now requires stdin, stdout, and stderr to all be non-terminals — any attached stream means a human can answer the unlock prompt on the controlling terminal or the GUI. Review feedback on #581.
All three stdio streams being non-terminals is not proof of headlessness: a macOS app or IDE task runner launches the CLI fully detached while the user can still answer the keychain unlock dialog on the WindowServer. Headless now additionally requires no GUI session — on darwin via `launchctl managername` != Aqua (bounded, cannot touch the keychain, failures count as no GUI so true headless keeps the bounded probe); elsewhere via DISPLAY/WAYLAND_DISPLAY, with Windows always false since Credential Manager never prompts. Review feedback on #581.
The !darwin implementation's claim that Windows is always non-GUI was false — that build returned true on any platform with DISPLAY set. A dedicated session_windows.go hard-codes false with the rationale, the unix implementation drops the stale claim, and the env test gains the Wayland-only branch. Review feedback on #581.
* Bound the keyring probe on headless sessions (Refs #568) Bumps github.com/basecamp/cli to the post-#56 credstore (v0.2.2-0.20260728023309-04e401b12c6c), which adds StoreOptions.ProbeTimeout: a bounded availability probe that kills the hung `security` child on darwin and falls back to file storage as if the probe had failed. The CLI wires it interactivity-aware: when stderr is not a terminal (CI, piped installers, ssh without a TTY) the probe is bounded at ten seconds — a headless session can never answer a keychain unlock prompt, so hanging forever in an uncancellable child was the only alternative (the #568 incident class, previously mitigated only by lazy construction and the BASECAMP_NO_KEYRING escape hatch). Interactive sessions keep the unbounded probe: a locked keychain there raises an unlock prompt, and cutting it off mid-answer would silently degrade the user to plaintext file storage. ForceFile is deliberately not wired: credstore's internal timeout-to-file fallback covers the CLI's need, and the env-var path already provides forced file mode. * Treat only fully detached sessions as headless (Refs #568) Stderr alone misclassified routine interactive use: `2>auth.log` from a terminal would have bounded the probe and, on a >10s unlock answer, silently degraded to plaintext file storage with the warning hidden in the redirected log. Headless now requires stdin, stdout, and stderr to all be non-terminals — any attached stream means a human can answer the unlock prompt on the controlling terminal or the GUI. Review feedback on #581. * Recognize GUI sessions before bounding the probe (Refs #568) All three stdio streams being non-terminals is not proof of headlessness: a macOS app or IDE task runner launches the CLI fully detached while the user can still answer the keychain unlock dialog on the WindowServer. Headless now additionally requires no GUI session — on darwin via `launchctl managername` != Aqua (bounded, cannot touch the keychain, failures count as no GUI so true headless keeps the bounded probe); elsewhere via DISPLAY/WAYLAND_DISPLAY, with Windows always false since Credential Manager never prompts. Review feedback on #581. * Split the Windows GUI-session stub into its own file (Refs #568) The !darwin implementation's claim that Windows is always non-GUI was false — that build returned true on any platform with DISPLAY set. A dedicated session_windows.go hard-codes false with the rationale, the unix implementation drops the stale claim, and the env test gains the Wayland-only branch. Review feedback on #581.
Phase 0's first run failed at the headless assertion, and it was right to. A GitHub macOS runner step reports `launchctl managername: Aqua` — the image has a GUI session — so sessionIsHeadless() is false there, ProbeTimeout is never set, and the bounded-probe path under test never executes. The premise that a hosted runner is headless was simply wrong; the assertion converted that into a red check instead of a green one over dead code. A diagnostic run measured the alternatives: direct (job step) Aqua sudo launchctl bsexec / System (root, wrong keychain domain) ssh localhost Background (tty: not a tty) Remote Login is already On in the image, so reaching a Background session costs only an authorized_keys entry. This is not a workaround for the runner. `ssh localhost` with no TTY *is* the #568 scenario — a piped installer over ssh with no way to answer an unlock prompt — so the test now reproduces the incident more faithfully than the original design did, rather than less. Consequent changes: the headless assertion now checks managername and tty in the ssh session; the disarm assertion checks the ssh session's environment, since a login profile could set either variable independently of the job; the default keychain is resolved and locked in that session, which is the securityd context the probe runs in; the watchdog runs remotely so it kills the binary rather than only the ssh client; and exit 255 counts as a watchdog fire, since ssh reports a signalled remote command that way. The floor, the ceiling, and the envelope assertion are unchanged.
* Automate the locked-keychain acceptance test The #568 incident class — on headless macOS with a locked login keychain, constructing the credential store blocked forever in an uncancellable `security` child — has unit coverage for each piece: the bounded probe kills and reaps its child, the store is lazy until the first credential op, and headless sessions get a 10s bound. What nothing covered was the composition: the real binary, on real macOS, against a really blocking keychain, completing inside the bound. That was a manual VM ritual, and it had not been done. An earlier attempt with a disposable HOME failed deceptively — `security -i` returned in 3.21s with -60006, a fast clean failure in which the cancellation path never ran. It would have "passed" while proving nothing. A hosted macOS runner is disposable, which dissolves the objection that forced the VM: nobody's keychain is harmed by locking it. So make it a black-box CI test. The floor assertion is what makes this a test rather than a ritual. A run that finishes in ~3s means the runner fast-failed and the timeout path never executed, so the job fails rather than emitting a green check that proves nothing — the disposable-HOME trap, promoted to CI where it would be trusted forever. The ceiling catches the regression. Whether a hosted runner actually reproduces the blocking behavior is unproven, so this lands as an experiment: the PR trigger and its self-referencing path filter let the job prove itself here. It becomes a release gate only after a run lands in the 8-25s band — wiring an unproven check into `release.yml` would either block releases on a void gate or bless one. `workflow_call` is why this is a separate file rather than a job in test.yml: `release.yml` can then invoke it against the exact tag SHA. A manual pre-run cannot gate a release, because scripts/release.sh pushes a release-prep commit to main before tagging, so anything run beforehand covers the wrong commit. That wiring is deliberately not in this change. * TEMP: probe whether a non-Aqua session is reachable on macos-latest Phase 0's first run failed at the headless assertion: macos-latest reports `launchctl managername: Aqua`, so sessionIsHeadless() is false and the bounded-probe path never engages. The assertion worked; the premise that a hosted runner is headless did not. This diagnostic job determines whether any invocation context on the runner yields a non-Aqua session. Removed before merge either way. * Run the acceptance command over ssh localhost, not in the job step Phase 0's first run failed at the headless assertion, and it was right to. A GitHub macOS runner step reports `launchctl managername: Aqua` — the image has a GUI session — so sessionIsHeadless() is false there, ProbeTimeout is never set, and the bounded-probe path under test never executes. The premise that a hosted runner is headless was simply wrong; the assertion converted that into a red check instead of a green one over dead code. A diagnostic run measured the alternatives: direct (job step) Aqua sudo launchctl bsexec / System (root, wrong keychain domain) ssh localhost Background (tty: not a tty) Remote Login is already On in the image, so reaching a Background session costs only an authorized_keys entry. This is not a workaround for the runner. `ssh localhost` with no TTY *is* the #568 scenario — a piped installer over ssh with no way to answer an unlock prompt — so the test now reproduces the incident more faithfully than the original design did, rather than less. Consequent changes: the headless assertion now checks managername and tty in the ssh session; the disarm assertion checks the ssh session's environment, since a login profile could set either variable independently of the job; the default keychain is resolved and locked in that session, which is the securityd context the probe runs in; the watchdog runs remotely so it kills the binary rather than only the ssh client; and exit 255 counts as a watchdog fire, since ssh reports a signalled remote command that way. The floor, the ceiling, and the envelope assertion are unchanged. * Block the probe by linker injection instead of a locked keychain Phase 0 established that a locked keychain cannot drive this test on a hosted runner: macos-26-arm64 returns errSecInteractionNotAllowed in under 200ms, so the 10s cancellation path never ran. The floor caught it. Rather than hunt for a real keychain that blocks, substitute a command that reliably does. credstore's securityPath is a package-level var initialized to a constant, so the linker can repoint it: -ldflags '-X=github.com/basecamp/cli/credstore.securityPath=/usr/bin/caffeinate' `caffeinate -i` runs until killed, matching how probeBounded invokes securityPath. Verified against the pinned credstore: the string is embedded in the injected build and absent from a control build. This needs no change to basecamp/cli. A production runtime override would have enlarged the executable-path trust boundary in shipped builds for no benefit; a link-time override touches only this binary, which is why it is named basecamp-hanging-probe and is no longer described as release-shaped. It is a composition gate on the source revision, not a test of the exact shipped artifact. The headless detection stays real — the command still runs over `ssh localhost` (Background, no TTY), and sessionIsHeadless() is what selects the bounded path. Only the blocking child is synthetic. Because securityPath is read solely by probeBounded, the floor is now self-proving across both seams: if headless detection breaks the store takes the unbounded probeDirect path through go-keyring, and if the injection breaks probeBounded execs the real `security` — either way the run returns in ~0.2s, lands under the floor, and goes red. A broken harness cannot masquerade as a passing test. Since the keychain is never touched, resolution, locking, unlocking and the keychain diagnostics are all gone. Added in their place: a direct assertion that the injection took, because `-X` against a renamed symbol is silently ignored by the linker and would otherwise surface only as an unexplained fast run; and a stray-child check, which observes the reaping property end-to-end. The floor, the ceiling, the watchdog and the envelope assertion are unchanged. * Make the acceptance test a release gate The workflow has now passed three consecutive runs at 10.294s, 10.202s and 10.197s — inside the 8-25s band with about 0.1s of variance — so it is deterministic enough to block publication. It is invoked as a called workflow rather than run beforehand because scripts/release.sh commits and pushes a release-prep commit to main before tagging, so a manual pre-run would cover the wrong SHA. As a `uses:` job it runs against the exact tag SHA. The calling job grants contents: read explicitly. release.yml sets permissions: {} at workflow level and a called workflow can only maintain or reduce the caller's token permissions, so without the grant actions/checkout inside the reusable workflow would have no repository access — a gate that fails for a reason unrelated to what it tests. release.yml is in the acceptance workflow's own path filter, so this change retriggers the test that it gates. * Make the reaping check binding, and stop promising a keychain test Two problems, both about a reader trusting something the job does not actually deliver. The stray-child check was observed, not enforced. It sat in the `if: always()` diagnostics step as `pgrep -fl ... || echo "<none>"`, so a surviving child would print and the step would continue — and with this wired into release.yml, a future reaping regression would scroll past a green release gate. It is now its own step, ahead of the envelope assertion, and a match exits non-zero. The match is anchored on the exact argv the probe spawns (`<path> -i`) rather than a bare substring, in both directions: an unrelated caffeinate elsewhere on the runner cannot fail the gate, and a real survivor cannot hide behind a loose match. Verified locally across all four cases — absent, present, after cleanup, and against an unrelated `caffeinate -d -t 5`. There is no race to sleep around: CommandContext kills and waits synchronously inside cmd.Run(), so the child is reaped before the binary exits. The names promised a locked-keychain test that no longer happens — the keychain is never touched now that the probe blocks by linker injection. That matters most in the release UI, which is where an operator lands when this gate fails. Renamed the workflow to "Headless Keyring Probe", the file to headless-probe-composition.yml, and the release job to headless-probe / "Headless keyring probe". "Keyring" is kept so the subject stays findable; "locked-keychain" is dropped because it is no longer true. * Label the diagnostic pgrep as reporting-only The binding reaping assertion and this diagnostic print look alike at a glance, and the diagnostic has now been flagged twice as an unenforced gate. Both times the gate was already in place a few steps above. Say so at the call site, and explain why the duplicate is deliberate: diagnostics run if: always(), so this still reports child state when the job failed before reaching the assertion. * Narrow the survivor claim: absence is not proof of reaping The step asserted the child was "killed and reaped". It cannot show that. `pgrep` finding nothing proves no process survived the CLI — not that the CLI parent waited on it. Had the parent killed without waiting, the child would be a zombie, reparented to PID 1 when the CLI exits, and likely reaped by init before the pgrep runs. The gate would pass with the parent-side wait missing, which is precisely the regression the wording implied it caught. Current credstore is safe — cmd.Run() waits — but this check would not notice if that stopped being true, so it must not be the thing anyone trusts for it. The authoritative parent-side proof stays where it can actually be made: credstore's darwin unit test pinning ESRCH. Narrowed the step name, its comments, and its output to the claim it actually supports, and recorded why the stronger claim is out of reach here: a composition test could only assert reaping by holding the CLI parent alive while inspecting child state, or by exposing a test-only wait result. Neither is warranted for a release gate.
What
BASH_SOURCE[0]when Bash reads the script from standard input.Why
The documented
curl ... | bashinstallation path runs withBASH_SOURCE[0]unset. The source guard added in #558 dereferences that value whileset -uis active, so the installer exits beforemainruns. Falling back to$0preserves direct and piped execution while still preventingmainfrom running when the script is sourced.Testing
bats e2e/installer.batsbin/ciSummary by cubic
Fix the installer so piping to
bashworks underset -u. The source guard now tolerates an unsetBASH_SOURCE[0], socurl ... | bashrunsmainas expected.[[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]to handle stdin execution while still preventing execution when sourced.bash, runs with the current$BASH, and asserts it fails on missingcurl(not on an unbound variable).Written for commit 0f3b638. Summary will update on new commits.