Skip to content

feat(packaging): publish the CLI to npm and PyPI - #2

Merged
adhikjoshi merged 1 commit into
mainfrom
feat/npm-pypi-packaging
Aug 24, 2026
Merged

feat(packaging): publish the CLI to npm and PyPI#2
adhikjoshi merged 1 commit into
mainfrom
feat/npm-pypi-packaging

Conversation

@adhikjoshi

@adhikjoshiadhikjoshi commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Homebrew, Scoop, deb and rpm already ship. npm and PyPI are where most agents and developers look first, and goreleaser publishes to neither — which is why the audit still scores the CLI as partial.

Both are built from the binaries goreleaser has already produced, never rebuilt, so every channel ships identical bytes for a tag.

Names

modelslab-cli on both registries — modelslab is taken on each by the respective SDK. Both packages still register the modelslab command.

npm install -g modelslab-cli
pip install modelslab-cli

npm: entry package + one package per platform

modelslab-cli contains only a launcher shim (2.6 kB) and declares six platform packages as optionalDependencies. npm installs the one matching os/cpu and skips the rest.

The obvious alternative — one package with a postinstall that downloads the binary — was rejected on purpose. It needs network at install time and produces a silently broken install under npm ci --ignore-scripts, which plenty of CI and agent sandboxes set. Six small packages buy an install that cannot half-work.

Three bugs, all found by installing rather than by reading

1. The shim only worked for a plain global install.require.resolve fails through any symlink, because resolution then starts from the link target, which has no node_modules. That's npm install <path>, npm link, and every pnpm install. It now tries three strategies: plain resolve, resolve anchored at the real directory and cwd, then a walk up looking for the sibling package.

2. pip did not preserve the executable bit. pip decides with stat.S_ISREG(mode) and mode & 0o111, so the zip entry's mode must carry the file-type bits, not just permissions. A bare 0o755 fails S_ISREG, the binary unpacks 0644, and the first run dies with EPERM:

PermissionError: [Errno 13] Permission denied

twine check passed all six wheels anyway. Only installing and running caught it.

3. Prerelease tags produced invalid wheel filenames. Tags are semver, wheel filenames are PEP 440. v1.2.3-rc1 naively yields modelslab_cli-1.2.3-rc1-py3-none-*.whl, which pip reads as version 1.2.3 with build tag rc1 — and build tags must start with a digit, so the file is simply invalid. Normalised to 1.2.3rc1, with anything unexpressible rejected outright.

Release wiring

  • Binaries are collected from goreleaser's artifacts.json, not by parsing dist/ directory names. A real snapshot build confirmed why: the dirs are modelslab_darwin_arm64_v8.0, modelslab_linux_amd64_v1 — microarchitecture suffixes that differ per target and move between goreleaser versions. My first attempt parsed the names and would have mangled them.
  • Publishing is guarded on the token being present, so a missing secret skips that registry instead of failing the release. secrets is not an available context in a step-level if, so tokens map to env and the guard reads that.
  • Platform packages publish before the entry package — it pins exact versions of all six, so the other order leaves a window where every install fails.

CI

ci.yml gains a packaging job that builds both and then installs and runs them. All three bugs above pass every static check; executing the result is the only test that means anything.

Verified locally

Against a real goreleaser build --snapshot:

6 binaries collected from artifacts.json
7 npm packages, 6 wheels built
twine check: 6 PASSED
npm install (symlinked) -> modelslab --version ✓ exit codes propagate
pip install wheel -> modelslab --version ✓ binary unpacks 0755

Go tests unaffected.

Needs you

NPM_TOKEN and PYPI_TOKEN in repository secrets. Until they're set the new steps no-op and the release behaves exactly as it does today. Both names are currently unregistered, so whoever holds the accounts should claim them before the next tag.


View with [code]smithAutofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Homebrew, Scoop, deb and rpm already ship; npm and PyPI are where most agents and
developers look first, and goreleaser publishes to neither. Both are built here
from the binaries goreleaser has already produced, so every channel ships the
same bytes for a tag.
Names are `modelslab-cli` on both registries. `modelslab` is taken on each by the
respective SDK; both packages still register the `modelslab` command.
npm: an entry package plus one package per platform
- `modelslab-cli` contains only a launcher shim and declares the six platform
packages as optionalDependencies. npm installs the one matching os/cpu.
- The obvious alternative, a postinstall that downloads the binary, was rejected:
it needs network at install time and produces a silently broken install under
`npm ci --ignore-scripts`, which plenty of CI and agent sandboxes set.
- The shim resolves the binary three ways. A plain require.resolve covers a
normal global install and nothing else — it fails through any symlink, which
means `npm install <path>`, `npm link`, and every pnpm install. Found by
installing the built package rather than by reading the code.
PyPI: one wheel per platform
- Each wheel carries the binary and a console script that execv's it. Wheels are
written directly rather than through a build backend; nothing is compiled, so a
backend would only add a dependency and hide the platform tag.
- Two bugs that static checks do not catch, both found by installing and running:
- pip decides executability with `stat.S_ISREG(mode) and mode & 0o111`, so the
zip entry needs the file-TYPE bits. A bare 0o755 fails S_ISREG, the binary
unpacks 0644, and the first run dies with EPERM. `twine check` passes it.
- Tags are semver, wheel filenames are PEP 440. `v1.2.3-rc1` naively yields
`modelslab_cli-1.2.3-rc1-py3-none-*.whl`, which pip reads as version 1.2.3
with build tag `rc1` — build tags must start with a digit, so the file is
invalid. Tags are normalised to `1.2.3rc1`.
Release and CI
- release.yml builds and publishes both on tag, guarded on the token being
configured so a missing secret skips that registry instead of failing the
release. `secrets` is not an available context in a step-level `if`, so the
tokens are mapped to env and the guard reads that.
- Binaries are collected from goreleaser's artifacts.json rather than by parsing
dist/ directory names: those carry microarchitecture suffixes (_v1, _v8.0) that
differ per target and move between goreleaser versions.
- ci.yml builds both packages on every PR and then installs and RUNS them. Both
failure modes above pass every static check, so the only test that means
anything is executing the result.
Verified locally against a real `goreleaser build --snapshot`: 6 binaries
collected, 7 npm packages and 6 wheels built, all wheels pass twine check, and
both an npm install and a wheel install produce a working `modelslab --version`.
Publishing needs NPM_TOKEN and PYPI_TOKEN in repository secrets; until they are
set the new steps no-op.
@adhikjoshi
adhikjoshi merged commit bd44984 into mainAug 24, 2026
8 checks passed
@adhikjoshi
adhikjoshi deleted the feat/npm-pypi-packaging branch August 24, 2026 08:57
Sign up for freeto 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

@adhikjoshi