Skip to content

fix(cli): mark platform binaries executable in pnpm publish tarball#5201

Merged
avallete merged 2 commits into
developfrom
fix/binary-targets-for-platform-packages
May 7, 2026
Merged

fix(cli): mark platform binaries executable in pnpm publish tarball#5201
avallete merged 2 commits into
developfrom
fix/binary-targets-for-platform-packages

Conversation

@Coly010
Copy link
Copy Markdown
Contributor

@Coly010 Coly010 commented May 7, 2026

Current Behavior

After #5199 switched the npm publisher from bun publish to pnpm publish, npx supabase@beta fails on first run:

spawnSync .../node_modules/@supabase/cli-darwin-arm64/bin/supabase EACCES

Per pnpm docs: “by default, for portability reasons, no files except those listed in the bin field will be marked as executable in the resulting package archive.”

The platform packages used to have "bin": { "supabase": "bin/supabase" }, but it was removed in 0067c4e (“remove bin field for binary only packages”). bun publish preserved source file modes so this was fine; pnpm publish does not, so the published tarballs ship bin/supabase and bin/supabase-go at mode 0644 and EACCES on consumer install.

The smoke-test job in .github/workflows/release-shared.yml re-applies chmod +x after artifact download, which is why CI smoke goes green; the publish job has no such step, so production tarballs ship without the bit.

Both binaries are affected: supabase (the bun-compiled main binary, failing in the trace above) and supabase-go (which LegacyGoProxy shell-outs would hit on every Phase-0 wrapped legacy command).

New Behavior

Add publishConfig.executableFiles to all 8 platform package.json files. This is pnpm’s documented mechanism for flagging files as executable in the published tarball without creating a bin symlink — so we don’t reintroduce the supabase bin name collision with the umbrella apps/cli/package.json that motivated 0067c4e.

supabase-go is only built for the legacy shell, but executableFiles is a no-op for files not present in the tarball, so listing it unconditionally is safe — alpha (next-shell) tarballs simply won’t contain bin/supabase-go and pnpm skips it silently.

Verified locally with pnpm pack: stub source files at mode 0644 are packed as -rwxr-xr-x (0755):

$ tar -tvf /tmp/supabase-cli-darwin-arm64-0.0.0.tgz
-rwxr-xr-x  0 0      0          20 26 Oct  1985 package/bin/supabase
-rwxr-xr-x  0 0      0          23 26 Oct  1985 package/bin/supabase-go
-rw-r--r--  0 0      0         640 26 Oct  1985 package/package.json

Test plan

  • CI Release / smoke-test matrix passes across ubuntu-latest, macos-latest, macos-15-intel, windows-latest.
  • After the next beta publish, npx supabase@beta --help succeeds on darwin-arm64 with no EACCES.
  • npx supabase@beta orgs list (a Phase-0 wrapped legacy command exercising supabase-go via LegacyGoProxy) succeeds without EACCES.
  • Inspect the published @supabase/cli-darwin-arm64@<version> tarball on npm and confirm bin/supabase and bin/supabase-go are mode 0755.

pnpm publish only sets +x on files declared in `bin` (or
`publishConfig.executableFiles`); other files default to 0644 in the
tarball. Since the bin field was removed from the platform packages in
0067c4e, the switch to `pnpm publish` in #5199 ships `bin/supabase`
and `bin/supabase-go` non-executable, breaking `npx supabase@beta`
with EACCES on first invocation of the CLI binary, and on every
LegacyGoProxy shell-out to supabase-go.

Add `publishConfig.executableFiles` to all 8 platform packages so the
tarballed binaries are 0755. Verified with `pnpm pack`: source files
at 0644 are packed as -rwxr-xr-x. `executableFiles` is a no-op for
entries not present in the tarball, so alpha (next-shell) builds —
which don't produce supabase-go — are unaffected.
@Coly010 Coly010 requested a review from a team as a code owner May 7, 2026 10:21
@Coly010 Coly010 self-assigned this May 7, 2026
…me (#5202)

## What kind of change does this PR introduce?

Bug fix.

## What is the current behavior?

The `beta` channel of the new TS-based release pipeline (introduced in
#39) ships brew/scoop artifacts that **install the binary as
`supabase-beta` instead of `supabase`**. This is a breaking change vs.
the historical Go CLI behavior, where `Formula/supabase-beta.rb` and
`supabase-beta.json` always installed a binary named `supabase` — only
the formula/manifest filename + Ruby class differed between channels.

Concretely, the currently-published `v2.99.0-beta.1` artifacts contain:

- `supabase/homebrew-tap` → `Formula/supabase-beta.rb`:

  ```ruby
  def install
    bin.install "supabase" => "supabase-beta"
    bin.install "supabase-go" if File.exist?("supabase-go")
  end

  test do
assert_match version.to_s, shell_output("#{bin}/supabase-beta
--version")
  end
  ```

- `supabase/scoop-bucket` → `supabase-beta.json`:

  ```json
  "bin": [["supabase.exe", "supabase-beta"]]
  ```

So users on the beta channel suddenly have to invoke `supabase-beta ...`
instead of `supabase ...` after a `brew upgrade` / `scoop update`,
breaking every existing script, CI job, and shell alias.

### Root cause

PR #39 added a `--name` flag to
`apps/cli/scripts/update-{homebrew,scoop}.ts` to support PoC validation
against user-owned forks (e.g. `supabase-shim-poc`). To avoid clashing
with reviewers' already-installed `supabase` CLI during PoC testing, the
same flag also **renamed the installed binary** when `--name !=
"supabase"`. When `release.yml` started passing `--name supabase-beta`
for the beta channel, that PoC-only side-effect leaked into production.

Compare with the Go CLI's `tools/publish/main.go` and the previous
`Formula/supabase-beta.rb` (`v2.98.2`), which both installed
`bin.install "supabase"` regardless of channel.

## What is the new behavior?

`--name` controls only:

- Homebrew formula filename (`supabase.rb` vs `supabase-beta.rb`) and
Ruby class name (`Supabase` vs `SupabaseBeta`)
- Scoop manifest filename (`supabase.json` vs `supabase-beta.json`)
- Commit message

The installed binary is **always** `supabase` / `supabase.exe`, matching
the Go CLI's historical behavior. Stable and beta still coexist as
separate formulas / manifests in the same tap / bucket — users just
choose one or the other (Homebrew detects the `supabase` binary
collision the same way it did with the Go CLI).

### Verification (dry-run, `--name supabase-beta`)

`Formula/supabase-beta.rb`:

```ruby
class SupabaseBeta < Formula
  desc "Supabase CLI"
  ...
  def install
    bin.install "supabase"
    bin.install "supabase-go" if File.exist?("supabase-go")
  end

  test do
    assert_match version.to_s, shell_output("#{bin}/supabase --version")
  end
end
```

`supabase-beta.json`:

```json
"bin": ["supabase.exe"]
```

Both match the Go CLI's `v2.98.2`-era output exactly.

## Additional context

### Files changed

- `apps/cli/scripts/update-homebrew.ts` — drop the rename branch; always
`bin.install "supabase"`; `brew test` invokes `#{bin}/supabase`.
- `apps/cli/scripts/update-scoop.ts` — drop the alias-tuple branch;
`binEntry` is always `"supabase.exe"`.
- `apps/cli/docs/release-process.md` — Ring 2 PoC section now notes that
the PoC formula installs a `supabase` binary, so reviewers must `brew
uninstall supabase` / `scoop uninstall supabase` first if they have the
official CLI installed. Validation snippets updated to invoke `supabase
--version` after `brew install supabase-shim-poc`.
- `docs/adr/0011-cli-release-and-distribution-strategy.md` —
Implementation progress §B updated to reflect that `--name` only
controls the filename/class, not the installed binary name.

### Hotfix note for already-published `v2.99.0-beta.1`

The next beta release pushed via `release-shared.yml` will overwrite
`Formula/supabase-beta.rb` and `supabase-beta.json` with the corrected
shape. If we want to unblock current beta users **before** the next beta
cuts, we'd need to either:

1. Manually edit `Formula/supabase-beta.rb` + `supabase-beta.json` on
`main` in `supabase/homebrew-tap` / `supabase/scoop-bucket` to match the
new template, or **done**
2. Run a manual `release.yml` `workflow_dispatch` with `channel=beta`
against the `v2.99.0-beta.1` version to republish the brew/scoop side
only.

Out of scope for this PR; flagging for the release operator.

### Test plan

- [x] `pnpm check:all` in `apps/cli` (types, lint, fmt, knip)
- [x] `pnpm test:core` in `apps/cli` (unit + integration)
- [x] Local dry-run of `update-homebrew.ts --name supabase-beta` —
generated formula matches the Go CLI's `v2.98.2`
`Formula/supabase-beta.rb` shape.
- [x] Local dry-run of `update-scoop.ts --name supabase-beta` —
generated manifest uses `"bin": ["supabase.exe"]`.
- [x] Local dry-run with default `--name supabase` (stable path) —
formula + manifest unchanged from prior correct behavior.
- [ ] Once merged + a beta release cuts, verify `brew install
supabase/homebrew-tap/supabase-beta && supabase --version` resolves to
the new beta version on macOS.
- [ ] Verify `scoop install supabase-beta && supabase --version` on
Windows.
@avallete avallete merged commit b2b397a into develop May 7, 2026
11 checks passed
@avallete avallete deleted the fix/binary-targets-for-platform-packages branch May 7, 2026 10:38
Coly010 added a commit that referenced this pull request May 7, 2026
## Current Behavior

The release-CI smoke test for the npm/Verdaccio sub-test reports `[npm]
Error: ShellError: Failed with exit code 1` on linux + macos with no
further detail. The `await $` invocation in
`apps/cli/tests/helpers/npm-registry.ts` swallows stderr on non-zero
exit, so the actual cause is invisible.

Worse: even when the test passes, it has been doing so for a false
reason. With the diagnostics added in this PR, two regressions surface
that have been masked for some time:

1. `npm install` ignores the test project's `.npmrc` in environments
where pnpm has set `npm_config_*` env vars. The install silently
resolves `supabase` against `registry.npmjs.org` instead of the local
Verdaccio, fetching the public 2.x CLI (76 transitive deps). The version
regex `/^\d+\.\d+\.\d+/` matches whatever that package prints, so the
smoke test reports PASS while exercising none of our build artifacts.
The recent CI failure is the public package's postinstall now failing on
the runners — the symptom, not the cause.
2. Verdaccio is configured with `uplinks: {}`, so even when the local
registry is hit, transitive deps from the umbrella's runtime
`dependencies` (`@clack/prompts`, `effect`, `ink`, `react`, …) 404.
Those deps are bundled into `dist/supabase.js` at build time but `npm
install` still resolves them.

`b2b397af`'s `publishConfig.executableFiles` fix was correct —
diagnostics confirm tarball entries land at mode `0755`. The smoke test
just never reached them.

Smoke tests also currently only run post-merge in `release-shared.yml`.
Packaging regressions therefore slip through PR review and only surface
when a release is cut — 2.99.0-beta.1 being the most recent example.

## New Behavior

**Diagnostics (`2beff61a`)**

- Capture stdout / stderr / exit-code on the verify spawn instead of
letting Bun's `\$` collapse them into an opaque \`ShellError\`.
- After publish, \`tar -tvf\` each platform tarball from Verdaccio
storage and log the \`bin/\` mode bits — directly answers whether
\`executableFiles\` is being honoured.
- After install, dump the resolved tree: \`.bin/supabase\`'s symlink
target + mode, the umbrella shim's mode, every unpacked
\`@supabase/cli-*/bin/\` entry, and each platform package's
\`name@version\`.
- On verify failure, retry against the platform binary directly to
isolate "shim broken" vs "binary broken".
- \`runCli\` (already in \`release-shell.ts\`) is now the single spawn
primitive for both checks; \`verifyExpectedShell\`'s failure detail
includes captured stderr.
- Smoke-test runners log \`e.stack\` + \`e.stdout\` / \`e.stderr\`
instead of \`\${e}\` on every catch.

**Fix (`2beff61a`)**

- Pass \`--registry \${verdaccio_url}\` explicitly to \`npm install\`.
The CLI flag wins over env vars and \`.npmrc\`, so the install can no
longer fall through to the public registry.
- Pin \`supabase\` and \`@supabase/*\` to local-only resolution in
Verdaccio's \`packages\` config; configure an \`npmjs\` uplink for
everything else so the umbrella's bundled-in runtime deps still resolve.
Mirrors what a real \`npm install supabase\` does today.

**PR-CI smoke job (`2b9e2bee`)**

\`.github/workflows/smoke-test-pr.yml\` is a thin caller that reuses
\`release-shared.yml\` with \`dry_run: true\`. The shared workflow
already gates publish jobs on \`!inputs.dry_run\`, so only \`build\` +
\`smoke-test\` actually run.

- Triggers on \`pull_request\` events with paths under \`apps/cli/**\`,
plus the workflow files themselves so changes to the workflow re-trigger
on the PR that introduced them.
- Synthesises a PR-scoped version (\`0.0.0-pr-\${PR_NUMBER}\`) so
concurrent PRs do not collide on the build artifact name.
- Skips drafts and cancels superseded runs.

## Test plan

- [x] \`nx run supabase:check:all\` (types/lint/fmt/knip).
- [x] Local smoke run with stub binaries: \`0.0.1-smoke\` resolves to
the local umbrella (not the public 2.x CLI), platform binary mode is
\`0755\`, \`supabase --version\` returns \`0.0.1-smoke\`.
- [ ] Release-CI \`smoke-test\` matrix passes on this branch's run.
- [ ] On this PR, the new \`Smoke Test (PR) / smoke / build\` and four
\`Smoke Test (PR) / smoke / smoke-test (...)\` jobs appear and pass.
- [ ] Confirm \`publish\` / \`publish-homebrew\` / \`publish-scoop\`
jobs from the called workflow report skipped on this PR.

## Related Issue(s)

Follow-up to #5199, #5200, #5201.
avallete added a commit that referenced this pull request May 20, 2026
Fixes the backfill-release-notes workflow so it actually produces the
right notes for an arbitrary historical tag, and moves the engine out of
inline bash into a reusable bun script.

## Why the original workflow couldn't produce correct notes

Backfilling an old tag isn't symmetric with running semantic-release on
a normal push — it trips on five separate things at once, each of which
silently sends it down a wrong path:

- **Branch detection.** `cycjimmy/semantic-release-action` reads the
branch via `env-ci` (`$GITHUB_REF` / `$GITHUB_REF_NAME`), not `git
rev-parse --abbrev-ref HEAD`. Dispatching the workflow from any branch
other than `develop`/`main` left semantic-release looking at an
unconfigured branch and silently emitting no `new_release_*` outputs.
- **"Behind remote" check.** semantic-release runs `git ls-remote
<repositoryUrl> <branch>` and exits silently if the remote tip differs
from local HEAD — which is always true when re-staging at an old tag.
- **Channel notes for historical tags.** semantic-release reads `git log
--notes=refs/notes/semantic-release*`. `actions/checkout` doesn't fetch
`refs/notes/*` by default, and some historical tags (e.g.
`v2.99.0-beta.1`) have no channel annotation at all — leaving them as
`channels=[null]`, which the prerelease filter drops. semantic-release
then walks past them and `lastRelease` drifts back far enough to drag
unrelated commits into the changelog.
- **Drift in `release.branches` config.** Before commit `2515885` (May
11) the `develop` branch had no explicit `"channel": "beta"`, so
semantic-release defaulted the channel to the branch name; before #5316
the plugin chain didn't include `release-notes-generator`. A historical
checkout therefore produces empty or misclassified notes.
- **Output format.** Parsing `cycjimmy/semantic-release-action`'s stdout
returns marked-terminal rendered ANSI/whitespace, not raw markdown — so
even when the right notes were computed, the GH release body would
render wrong.

## What this PR does

Moves the workflow's logic into
`apps/cli/scripts/backfill-release-notes.ts` and calls
`semantic-release` *programmatically* so it can return
`nextRelease.notes` as raw markdown directly. The script's setup works
around each of the issues above in a temp clone (so the original
workspace stays clean):

1. Clones the repo to a temp directory and fetches `refs/notes/*` from
both the source repo and origin.
2. Synthesises a `develop`/`main` branch at the tag's commit and seeds
the other configured branch from `refs/remotes/origin/<other>`.
3. Backfills missing channel notes on every reachable tag (`v*-beta.*` →
`beta`, `v*-alpha.*` → `alpha`, else `latest`).
4. Patches the temp clone's `apps/cli/package.json` with the *current*
`release` config so historical checkouts use today's `channel: "beta"`
and plugin chain.
5. Uses `git config --local url.<local>.insteadOf <github>` so
semantic-release's `ls-remote` silently targets the local clone
(satisfying the "behind remote" check) while `repositoryUrl` stays the
real GitHub URL — keeping commit/PR links correct in the rendered notes.
6. Calls `semanticRelease({ dryRun: true, noCi: true, repositoryUrl }, {
cwd: ... })` and prints `nextRelease.notes` to stdout, or with `--apply`
calls `gh release edit --notes-file`.

The workflow itself reduces to: checkout, setup, `bun
apps/cli/scripts/backfill-release-notes.ts --tag $TAG`, mirror to the
job summary, then conditionally re-run with `--apply`.

## Sample output (`v2.99.0-beta.2`)

```sh
$ bun apps/cli/scripts/backfill-release-notes.ts --tag v2.99.0-beta.2
```

```markdown
# [2.99.0-beta.2](v2.99.0-beta.1...v2.99.0-beta.2) (2026-05-20)


### Bug Fixes

* **ci:** make release re-cut reliable after stale-bytes runs ([#5209](#5209)) ([ef1b13a](ef1b13a)), closes [#5205](#5205) [#5207](#5207) [#5205](#5205) [#5207](#5207) [#App](https://github.com/supabase/cli/issues/App)
* **cli:** make npm publish idempotent for partial-failure re-runs ([#5207](#5207)) ([bbeaec7](bbeaec7)), closes [#release-creation](https://github.com/supabase/cli/issues/release-creation)
* **cli:** mark platform binaries executable in pnpm publish tarball ([#5201](#5201)) ([b2b397a](b2b397a)), closes [#5199](#5199)
* **cli:** smoke test now actually verifies our local build ([#5205](#5205)) ([9109056](9109056)), closes [#5199](#5199) [#5200](#5200)
```

Exactly the 5 commits between `v2.99.0-beta.1` and `v2.99.0-beta.2`,
with GitHub links rendered against `https://github.com/supabase/cli`
regardless of the local-clone redirection.

## Related

The same `release.repositoryUrl` / `ls-remote` interaction and
channel-notes drift will need to be addressed on the production publish
path (`.github/workflows/release-shared.yml`) once Stage B of the
changelog plumbing (`claude/wire-release-notes-publish`) lands.

---------

Co-authored-by: Claude <noreply@anthropic.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