Skip to content

revert: node must stay a required dependency, not :build - #10

Merged
samyakLambda merged 1 commit into
mainfrom
revert/homebrew-node-required-dep
Aug 17, 2026
Merged

revert: node must stay a required dependency, not :build#10
samyakLambda merged 1 commit into
mainfrom
revert/homebrew-node-required-dep

Conversation

@samyakLambda

Copy link
Copy Markdown
Contributor

Reverts the depends_on change from #9. Merging immediately — main is
currently broken for a fresh brew install lambdatest/rook/rook on any
machine without Homebrew's own node already installed.

What went wrong

#9 scoped depends_on "node" to => :build, reasoning that node/npm
are only invoked inside def install, which never runs on a bottle
pour — true, but incomplete.

Homebrew's own build finalizer (Cleaner#rewrite_shebangs, via
Language::Node::Shebang.detected_node_shebang in
language/node.rb) automatically rewrites any #!/usr/bin/env node
shebang in an installed formula's files to the absolute path of
that formula's required Node dependency —
formula.deps.select(&:required?) — silently skipping :build-scoped
ones (caught by a rescued ShebangDetectionError).

rook's published npm package ships bin/rook.cjs with exactly that
shebang — a small trampoline meant to bootstrap under whatever Node
launched it, then re-exec into the bundled runtime. Under the
previous unscoped depends_on "node", Homebrew silently rewrote
that shebang to Homebrew's own absolute node path when the 0.1.0
bottle was originally built — which is what actually made the bottle
run, since Homebrew's node was also always force-installed alongside
it (the exact behavior #497/#9 set out to remove).

Scoping to :build stopped forcing that install, but the
already-published 0.1.0 bottle still has the old absolute path
hardcoded into it — bottles are immutable. Confirmed live, dispatching
brew-smoke against #9's merge commit on real GitHub Actions runners:

macos-arm:  /opt/homebrew/bin/rook: /opt/homebrew/opt/node/bin/node:
            bad interpreter: No such file or directory
linux-x64:  /home/linuxbrew/.linuxbrew/bin/rook: cannot execute:
            required file not found

Worse than the original complaint — that was extra-but-working; this
is completely broken on any machine without Homebrew's node already
present.

This PR

Reverts depends_on "node" => :build back to depends_on "node",
with a long comment on the line explaining why, and inverts
brew-smoke.yml's dependency-scope assertion to guard against
re-making the same mistake (it now fails CI if node stops being a
required dependency, citing the mechanism, instead of only failing at
real-install time).

The extra-Node-install complaint (#497's original ask) is not fixed
by this revert
— it's back to the original behavior. A real fix
needs to be more than a one-line scope change: either accept the extra
install as a load-bearing side effect of this shebang-rewrite
mechanism and correct the formula's caveats text (which currently
overstates how self-contained this is), or replace the Homebrew-path
launcher with a real shell-script wrapper — like the curl/tarball path
already uses — so it never depends on env node at runtime at all.
Left as a follow-up.

#498 (the piped-installer crash) is unaffected — different file,
unrelated mechanism, unchanged here.

Test status

ruby -c, actionlint, YAML parse, all 6 scripts/test-*.sh
harnesses green. Will re-dispatch a live brew-smoke run against this
branch's merge commit to confirm installs work again before
considering this closed.


Agent-authored: Claude Sonnet 5, Claude Code CLI.

PR #9 scoped `depends_on "node"` to `=> :build`, reasoning that node/npm
are only invoked inside `def install`, which never runs on a bottle
pour. That's true, but incomplete: Homebrew's own build finalizer
(Cleaner#rewrite_shebangs, which calls
Language::Node::Shebang.detected_node_shebang) automatically rewrites
any `#!/usr/bin/env node` shebang in an installed formula's files to
the absolute path of that formula's *required* Node dependency —
`formula.deps.select(&:required?)` in language/node.rb — silently
skipping :build-scoped ones.

rook's published npm package ships `bin/rook.cjs` with exactly that
`#!/usr/bin/env node` shebang (a trampoline meant to bootstrap under
whatever Node launched it, then re-exec into the bundled runtime).
Under the previous unscoped `depends_on "node"`, Homebrew silently
rewrote that shebang to Homebrew's own absolute node path when the
0.1.0 bottle was built — which is what made the bottle actually run,
since Homebrew's node was also always force-installed alongside it.

Scoping to :build stopped forcing that install, but did nothing about
the already-published 0.1.0 bottle, which still has the old absolute
path hardcoded in. Confirmed live: a fresh `brew install
lambdatest/rook/rook` on a real macOS-14 and ubuntu-latest GitHub
Actions runner (no Homebrew node preinstalled) both failed identically
pouring that exact bottle:

  macos-arm:  /opt/homebrew/bin/rook: /opt/homebrew/opt/node/bin/node:
              bad interpreter: No such file or directory
  linux-x64:  /home/linuxbrew/.linuxbrew/bin/rook: cannot execute:
              required file not found

Worse than the original complaint (extra Node installed, but working):
this made the bottle fail to execute at all on any machine without
Homebrew's node already present. Reverting to keep installs working;
the actual fix for the extra-Node-install complaint needs to be more
than a one-line dependency scope change — either accept it as a
load-bearing implementation detail of this shebang-rewrite mechanism
and correct the formula's caveats text (which currently overstates how
self-contained this is), or replace the Homebrew-path launcher with a
real shell-script wrapper like the curl/tarball path already uses, so
it never depends on `env node` at runtime. Left as a follow-up, not
decided here.

brew-smoke.yml's dependency-scope check is inverted to assert node
STAYS required, with the mechanism cited inline, so re-attempting the
:build scoping trips CI before a real install does.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@samyakLambda
samyakLambda merged commit 756a39b into main Aug 17, 2026
2 checks passed
samyakLambda added a commit that referenced this pull request Aug 17, 2026
#11)

Real fix for #497 (the extra-Node-install complaint) — not the caveats-text
option, the shell-wrapper option. Both were on the table after the revert in
#10; this is the one I picked, with reasoning below.

## Why this, not documenting the waste as intentional

`rook`'s published npm package ships `bin/rook.cjs`: a small trampoline with
a `#!/usr/bin/env node` shebang, designed to bootstrap under whatever Node
launched it, resolve the bundled runtime via `require.resolve`, and re-exec
into it. Under the old, `:required` `depends_on "node"`, Homebrew's own
`Cleaner#rewrite_shebangs` (`language/node.rb`) silently rewrote that
shebang to the absolute path of Homebrew's own node at build time — which
is what made the bottle work, and also exactly the extra install #497
complains about.

The trampoline's whole reason to exist is not knowing at *publish* time
whether a bundled runtime will be present. Homebrew's `def install` **does**
know — it just finished downloading and chmod'ing that exact binary. So
instead of relying on npm's shim plus Homebrew's shebang-rewrite trick, this
PR has `def install` write its own launcher directly:

```sh
#!/bin/sh
exec "<absolute path to the bundled node binary>" "<absolute path to dist/cli.js>" "$@"
```

Only in the success path (bundled runtime installed and verified) — the
existing `opoo`-and-fall-back-to-system-Node path is untouched, since npm's
original trampoline is exactly the right behavior there.

This is strictly better than either alternative on the table:
- **Actually fixes #497**: nothing in the installed tree has an `env node`
  shebang left for Homebrew to rewrite (or fail to) — `depends_on "node"`
  goes back to `=> :build`, this time correctly, since nothing depends on
  the rewrite happening.
- **No changes needed to the private monorepo** — `bin/rook.cjs` is
  untouched; it's still exactly right for npm-direct/internal installs.
- **Simpler signal handling than the trampoline it replaces, for this
  path**: `exec` replaces the shell's own process image, so a signal sent
  to the launcher's PID lands on the bundled node process natively, same
  PID, no relay. `bin/rook.cjs`'s ~150 lines of SIGINT/SIGTERM/SIGHUP
  forwarding exist specifically because `spawn()` creates a *child*
  process that needs that relay — `exec` in a shell script doesn't have
  that problem to begin with.

Absolute paths baked into an installed script this way are ordinary,
relocatable Homebrew practice — any formula that writes a wrapper
referencing `#{libexec}` does the same; nothing new here.

## Verification

Local `brew install --build-bottle` turned out to be broken in my
environment for reasons unrelated to this change — I confirmed this by
installing the **already CI-verified, currently-live** formula (pre-this-PR)
and getting the identical `FormulaUnavailableError`. Rather than fight that,
I pulled the real published `@testmuai/rook` and bundled-runtime npm
packages, reconstructed `def install`'s exact `pkg_dir` layout by hand, and
ran the actual generated wrapper end to end with `PATH=/usr/bin:/bin` (no
node, no Homebrew, nothing):

```
$ env -i PATH="/usr/bin:/bin" ./rook --version
0.1.0
$ env -i PATH="/usr/bin:/bin" ./rook --help
Usage: rook [options] [command]
...
```

All 6 `scripts/test-*.sh` harnesses green, `ruby -c`, `actionlint` clean.

## What it takes to actually ship

A bottle *pours* a pre-built keg — it never runs `def install`. Merging
this alone changes nothing for real users; `def install`'s new code only
takes effect once `build-bottles.yml` rebuilds and republishes the 0.1.0
bottle. I'll dispatch that after merge and live-verify `brew-smoke` green
on both platforms before considering this done — the same live-verification
discipline that caught the #9 regression, this time before declaring
victory instead of after.

New `test do` assertion added as a structural guard on the actual
mechanism (checks the launcher's shebang, not just that `rook --version`
happens to work) — `brew-smoke.yml`'s existing "node stays required" guard
is flipped back too, this time for the right reason.

---
Agent-authored: Claude Sonnet 5, Claude Code CLI.
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.

1 participant