Skip to content

Commit bd44984

Browse files
authored
feat(packaging): publish the CLI to npm and PyPI (#2)
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.
1 parent 93bb05e commit bd44984

10 files changed

Lines changed: 795 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,71 @@ jobs:
3737
- name: Integration Tests (no auth)
3838
run: go test ./tests/ -v -count=1 -timeout 120s
3939

40+
# The npm and PyPI packagers consume goreleaser's output. Nothing else in CI
41+
# exercises them, so without this a packaging break is only discovered by a
42+
# tag push — i.e. by a broken release.
43+
packaging:
44+
runs-on: ubuntu-latest
45+
needs: test
46+
steps:
47+
- uses: actions/checkout@v4
48+
49+
- uses: actions/setup-go@v5
50+
with:
51+
go-version: "1.26"
52+
53+
- uses: actions/setup-node@v4
54+
with:
55+
node-version: "20"
56+
57+
- uses: actions/setup-python@v5
58+
with:
59+
python-version: "3.12"
60+
61+
- uses: goreleaser/goreleaser-action@v6
62+
with:
63+
distribution: goreleaser
64+
version: "~> v2"
65+
args: build --snapshot --clean
66+
67+
- name: Collect binaries
68+
run: |
69+
set -euo pipefail
70+
jq -r '.[] | select(.type == "Binary") | "\(.goos)_\(.goarch)\t\(.path)"' \
71+
dist/artifacts.json |
72+
while IFS=$'\t' read -r target path; do
73+
mkdir -p "artifacts/$target"
74+
cp "$path" "artifacts/$target/"
75+
done
76+
test "$(find artifacts -type f | wc -l)" -eq 6
77+
78+
- name: Build npm packages
79+
run: node packaging/npm/build.mjs v0.0.0 artifacts dist/npm
80+
81+
- name: Install and run the npm package
82+
run: |
83+
set -euo pipefail
84+
mkdir -p /tmp/npmcheck && cd /tmp/npmcheck && npm init -y >/dev/null
85+
npm install --no-audit --no-fund \
86+
"$GITHUB_WORKSPACE/dist/npm/modelslab-cli-linux-x64" \
87+
"$GITHUB_WORKSPACE/dist/npm/modelslab-cli"
88+
# The real check: the shim resolves the binary and the binary runs.
89+
./node_modules/.bin/modelslab --version
90+
91+
- name: Build PyPI wheels
92+
run: python3 packaging/pypi/build.py v0.0.0 artifacts dist/pypi
93+
94+
- name: Install and run the wheel
95+
run: |
96+
set -euo pipefail
97+
python3 -m pip install --quiet twine
98+
python3 -m twine check dist/pypi/*.whl
99+
python3 -m pip install --quiet \
100+
dist/pypi/modelslab_cli-0.0.0-py3-none-manylinux2014_x86_64.whl
101+
# Catches the executable-bit bug: twine check passes a wheel whose
102+
# binary unpacks 0644, and only running it fails.
103+
modelslab --version
104+
40105
build-matrix:
41106
runs-on: ubuntu-latest
42107
needs: test

.github/workflows/release.yml

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,65 @@ jobs:
4343
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
4444
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
4545
SCOOP_BUCKET_GITHUB_TOKEN: ${{ secrets.SCOOP_BUCKET_GITHUB_TOKEN }}
46+
47+
# Both registries package the SAME binaries goreleaser just built, taken
48+
# from dist/ rather than rebuilt, so npm, PyPI, Homebrew and Scoop can
49+
# never ship different bytes for one tag.
50+
- name: Collect release binaries
51+
run: |
52+
set -euo pipefail
53+
# Read goreleaser's own manifest rather than parsing dist/ directory
54+
# names: those carry microarchitecture suffixes (_v1, _v8.0) that move
55+
# between goreleaser versions, and artifacts.json states goos/goarch
56+
# outright.
57+
jq -r '.[] | select(.type == "Binary") | "\(.goos)_\(.goarch)\t\(.path)"' \
58+
dist/artifacts.json |
59+
while IFS=$'\t' read -r target path; do
60+
mkdir -p "artifacts/$target"
61+
cp "$path" "artifacts/$target/"
62+
done
63+
find artifacts -type f | sort
64+
test "$(find artifacts -type f | wc -l)" -eq 6
65+
66+
- uses: actions/setup-node@v4
67+
with:
68+
node-version: "20"
69+
registry-url: "https://registry.npmjs.org"
70+
71+
- name: Build npm packages
72+
run: node packaging/npm/build.mjs "${GITHUB_REF_NAME}" artifacts dist/npm
73+
74+
# `secrets` is not an available context in a step-level `if`, so the token
75+
# is mapped to env and the guard reads that. Without the guard, a fork or a
76+
# repo that has not configured the token fails the whole release.
77+
- name: Publish to npm
78+
env:
79+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
80+
if: ${{ env.NODE_AUTH_TOKEN != '' }}
81+
run: |
82+
set -euo pipefail
83+
# Platform packages first: the entry package depends on them, and an
84+
# entry published against versions that do not exist yet is an install
85+
# that fails for everyone until the next step lands.
86+
for pkg in dist/npm/modelslab-cli-*; do
87+
npm publish "$pkg" --access public --provenance
88+
done
89+
npm publish dist/npm/modelslab-cli --access public --provenance
90+
91+
- uses: actions/setup-python@v5
92+
with:
93+
python-version: "3.12"
94+
95+
- name: Build PyPI wheels
96+
run: python3 packaging/pypi/build.py "${GITHUB_REF_NAME}" artifacts dist/pypi
97+
98+
- name: Publish to PyPI
99+
env:
100+
TWINE_USERNAME: __token__
101+
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
102+
if: ${{ env.TWINE_PASSWORD != '' }}
103+
run: |
104+
set -euo pipefail
105+
python3 -m pip install --quiet twine
106+
python3 -m twine check dist/pypi/*.whl
107+
python3 -m twine upload dist/pypi/*.whl

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ go.work.sum
1919
# GoReleaser
2020
dist/
2121

22+
# Binaries collected from a release for the npm/PyPI packagers
23+
artifacts/
24+
2225
# IDE
2326
.idea/
2427
.vscode/
@@ -35,3 +38,4 @@ Thumbs.db
3538

3639
# Generated output
3740
generated/
41+
__pycache__/

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,26 @@ The official command-line interface for [ModelsLab](https://modelslab.com) — m
66

77
## Installation
88

9+
### npm
10+
11+
```bash
12+
npm install -g modelslab-cli
13+
```
14+
15+
Ships the prebuilt binary for your platform as an optional dependency — nothing
16+
is compiled and no install script runs.
17+
18+
### PyPI
19+
20+
```bash
21+
pip install modelslab-cli
22+
```
23+
24+
Platform wheels; no build step and no Python dependencies.
25+
26+
<sub>Both register the `modelslab` command. The `modelslab` packages on npm and
27+
PyPI are the **SDKs**, not this CLI — hence the `-cli` suffix.</sub>
28+
929
### macOS (Homebrew)
1030

1131
```bash

packaging/README.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Packaging
2+
3+
The CLI is a single Go binary. Homebrew, Scoop, deb and rpm are produced by
4+
goreleaser directly; npm and PyPI are not registries goreleaser publishes to, so
5+
they are built here from the binaries goreleaser has already produced.
6+
7+
Nothing in this directory compiles anything. Both builders take a directory of
8+
extracted release binaries and repackage them, so every channel ships the same
9+
bytes for a given tag.
10+
11+
```
12+
artifacts/
13+
darwin_amd64/modelslab
14+
darwin_arm64/modelslab
15+
linux_amd64/modelslab
16+
linux_arm64/modelslab
17+
windows_amd64/modelslab.exe
18+
windows_arm64/modelslab.exe
19+
```
20+
21+
## npm — `packaging/npm/build.mjs`
22+
23+
```bash
24+
node packaging/npm/build.mjs v1.2.3 artifacts dist/npm
25+
```
26+
27+
Produces seven packages: one entry package (`modelslab-cli`) whose only content
28+
is a launcher shim, plus one package per platform holding just the binary and
29+
the `os`/`cpu` fields npm filters on. The entry package declares the six as
30+
`optionalDependencies`, so npm installs exactly the one that matches.
31+
32+
The alternative — one package with a postinstall that downloads a binary — was
33+
rejected deliberately. It needs network at install time and produces a silently
34+
broken install under `npm ci --ignore-scripts`, which many CI and agent sandboxes
35+
set. Six small packages buy an install that cannot half-work.
36+
37+
**Publish platform packages before the entry package.** The entry package pins
38+
exact versions of all six; publishing it first leaves a window where every
39+
install fails.
40+
41+
## PyPI — `packaging/pypi/build.py`
42+
43+
```bash
44+
python3 packaging/pypi/build.py v1.2.3 artifacts dist/pypi
45+
```
46+
47+
Produces one wheel per platform, each containing the binary and a console script
48+
that `execv`s it. Wheels are written directly rather than through a build
49+
backend: there is nothing to compile, so a backend would only add a dependency,
50+
and writing them here keeps the platform tag explicit instead of inferred from
51+
whatever host ran the build.
52+
53+
Two things that are easy to get wrong and are covered by CI:
54+
55+
- **Version normalisation.** Tags are semver, wheel filenames are PEP 440. A
56+
`v1.2.3-rc1` tag naively becomes `modelslab_cli-1.2.3-rc1-py3-none-*.whl`,
57+
which pip reads as version `1.2.3` with build tag `rc1` — and build tags must
58+
start with a digit, so the file is invalid. `normalise_version()` converts it
59+
to `1.2.3rc1`.
60+
- **The executable bit.** pip decides whether to mark an unpacked file executable
61+
with `stat.S_ISREG(mode) and mode & 0o111`, so the zip entry's mode has to
62+
carry the file-type bits, not just permissions. A bare `0o755` fails `S_ISREG`,
63+
the binary lands `0o644`, and the first run dies with `EPERM`. `twine check`
64+
passes either way; only installing and running catches it.
65+
66+
## Releasing
67+
68+
`.github/workflows/release.yml` runs both builders on a tag and publishes if the
69+
corresponding token is configured. Missing tokens skip that registry rather than
70+
failing the release.
71+
72+
| Secret | Registry |
73+
| --- | --- |
74+
| `NPM_TOKEN` | npm (automation token with publish rights) |
75+
| `PYPI_TOKEN` | PyPI (project or account API token, used as `__token__`) |
76+
77+
`.github/workflows/ci.yml` builds both on every PR and installs and *runs* the
78+
result, because both of the failure modes above pass every static check.

packaging/npm/README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# ModelsLab CLI
2+
3+
AI generation and account management from the terminal. One command surface over
4+
the ModelsLab API: image, video, audio, 3D and LLM generation, plus authentication,
5+
billing, wallet, subscriptions and model discovery.
6+
7+
```bash
8+
npm install -g modelslab-cli
9+
modelslab auth login
10+
modelslab generate image --prompt "a lighthouse at dusk" --model flux
11+
```
12+
13+
The package installs a prebuilt binary for your platform as an optional
14+
dependency — nothing is compiled and no install script runs.
15+
16+
Supported: macOS (Intel, Apple Silicon), Linux (x64, arm64), Windows (x64, arm64).
17+
18+
- Docs: https://docs.modelslab.com
19+
- Source: https://github.com/ModelsLab/modelslab-cli
20+
- Other install methods (Homebrew, Scoop, shell): https://modelslab.sh

0 commit comments

Comments
 (0)