Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -37,6 +37,71 @@ jobs:
- name: Integration Tests (no auth)
run: go test ./tests/ -v -count=1 -timeout 120s

# The npm and PyPI packagers consume goreleaser's output. Nothing else in CI
# exercises them, so without this a packaging break is only discovered by a
# tag push — i.e. by a broken release.
packaging:
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version: "1.26"

- uses: actions/setup-node@v4
with:
node-version: "20"

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: "~> v2"
args: build --snapshot --clean

- name: Collect binaries
run: |
set -euo pipefail
jq -r '.[] | select(.type == "Binary") | "\(.goos)_\(.goarch)\t\(.path)"' \
dist/artifacts.json |
while IFS=$'\t' read -r target path; do
mkdir -p "artifacts/$target"
cp "$path" "artifacts/$target/"
done
test "$(find artifacts -type f | wc -l)" -eq 6

- name: Build npm packages
run: node packaging/npm/build.mjs v0.0.0 artifacts dist/npm

- name: Install and run the npm package
run: |
set -euo pipefail
mkdir -p /tmp/npmcheck && cd /tmp/npmcheck && npm init -y >/dev/null
npm install --no-audit --no-fund \
"$GITHUB_WORKSPACE/dist/npm/modelslab-cli-linux-x64" \
"$GITHUB_WORKSPACE/dist/npm/modelslab-cli"
# The real check: the shim resolves the binary and the binary runs.
./node_modules/.bin/modelslab --version

- name: Build PyPI wheels
run: python3 packaging/pypi/build.py v0.0.0 artifacts dist/pypi

- name: Install and run the wheel
run: |
set -euo pipefail
python3 -m pip install --quiet twine
python3 -m twine check dist/pypi/*.whl
python3 -m pip install --quiet \
dist/pypi/modelslab_cli-0.0.0-py3-none-manylinux2014_x86_64.whl
# Catches the executable-bit bug: twine check passes a wheel whose
# binary unpacks 0644, and only running it fails.
modelslab --version

build-matrix:
runs-on: ubuntu-latest
needs: test
Expand Down
62 changes: 62 additions & 0 deletions .github/workflows/release.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -43,3 +43,65 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
SCOOP_BUCKET_GITHUB_TOKEN: ${{ secrets.SCOOP_BUCKET_GITHUB_TOKEN }}

# Both registries package the SAME binaries goreleaser just built, taken
# from dist/ rather than rebuilt, so npm, PyPI, Homebrew and Scoop can
# never ship different bytes for one tag.
- name: Collect release binaries
run: |
set -euo pipefail
# Read goreleaser's own manifest rather than parsing dist/ directory
# names: those carry microarchitecture suffixes (_v1, _v8.0) that move
# between goreleaser versions, and artifacts.json states goos/goarch
# outright.
jq -r '.[] | select(.type == "Binary") | "\(.goos)_\(.goarch)\t\(.path)"' \
dist/artifacts.json |
while IFS=$'\t' read -r target path; do
mkdir -p "artifacts/$target"
cp "$path" "artifacts/$target/"
done
find artifacts -type f | sort
test "$(find artifacts -type f | wc -l)" -eq 6

- uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"

- name: Build npm packages
run: node packaging/npm/build.mjs "${GITHUB_REF_NAME}" artifacts dist/npm

# `secrets` is not an available context in a step-level `if`, so the token
# is mapped to env and the guard reads that. Without the guard, a fork or a
# repo that has not configured the token fails the whole release.
- name: Publish to npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
if: ${{ env.NODE_AUTH_TOKEN != '' }}
run: |
set -euo pipefail
# Platform packages first: the entry package depends on them, and an
# entry published against versions that do not exist yet is an install
# that fails for everyone until the next step lands.
for pkg in dist/npm/modelslab-cli-*; do
npm publish "$pkg" --access public --provenance
done
npm publish dist/npm/modelslab-cli --access public --provenance

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Build PyPI wheels
run: python3 packaging/pypi/build.py "${GITHUB_REF_NAME}" artifacts dist/pypi

- name: Publish to PyPI
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
if: ${{ env.TWINE_PASSWORD != '' }}
run: |
set -euo pipefail
python3 -m pip install --quiet twine
python3 -m twine check dist/pypi/*.whl
python3 -m twine upload dist/pypi/*.whl
4 changes: 4 additions & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,6 +19,9 @@ go.work.sum
# GoReleaser
dist/

# Binaries collected from a release for the npm/PyPI packagers
artifacts/

# IDE
.idea/
.vscode/
Expand All@@ -35,3 +38,4 @@ Thumbs.db

# Generated output
generated/
__pycache__/
20 changes: 20 additions & 0 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -6,6 +6,26 @@ The official command-line interface for [ModelsLab](https://modelslab.com) — m

## Installation

### npm

```bash
npm install -g modelslab-cli
```

Ships the prebuilt binary for your platform as an optional dependency — nothing
is compiled and no install script runs.

### PyPI

```bash
pip install modelslab-cli
```

Platform wheels; no build step and no Python dependencies.

<sub>Both register the `modelslab` command. The `modelslab` packages on npm and
PyPI are the **SDKs**, not this CLI — hence the `-cli` suffix.</sub>

### macOS (Homebrew)

```bash
Expand Down
78 changes: 78 additions & 0 deletions packaging/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
# Packaging

The CLI is a single Go binary. Homebrew, Scoop, deb and rpm are produced by
goreleaser directly; npm and PyPI are not registries goreleaser publishes to, so
they are built here from the binaries goreleaser has already produced.

Nothing in this directory compiles anything. Both builders take a directory of
extracted release binaries and repackage them, so every channel ships the same
bytes for a given tag.

```
artifacts/
darwin_amd64/modelslab
darwin_arm64/modelslab
linux_amd64/modelslab
linux_arm64/modelslab
windows_amd64/modelslab.exe
windows_arm64/modelslab.exe
```

## npm — `packaging/npm/build.mjs`

```bash
node packaging/npm/build.mjs v1.2.3 artifacts dist/npm
```

Produces seven packages: one entry package (`modelslab-cli`) whose only content
is a launcher shim, plus one package per platform holding just the binary and
the `os`/`cpu` fields npm filters on. The entry package declares the six as
`optionalDependencies`, so npm installs exactly the one that matches.

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

**Publish platform packages before the entry package.** The entry package pins
exact versions of all six; publishing it first leaves a window where every
install fails.

## PyPI — `packaging/pypi/build.py`

```bash
python3 packaging/pypi/build.py v1.2.3 artifacts dist/pypi
```

Produces one wheel per platform, each containing the binary and a console script
that `execv`s it. Wheels are written directly rather than through a build
backend: there is nothing to compile, so a backend would only add a dependency,
and writing them here keeps the platform tag explicit instead of inferred from
whatever host ran the build.

Two things that are easy to get wrong and are covered by CI:

- **Version normalisation.** Tags are semver, wheel filenames are PEP 440. A
`v1.2.3-rc1` tag naively becomes `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 invalid. `normalise_version()` converts it
to `1.2.3rc1`.
- **The executable bit.** pip decides whether to mark an unpacked file executable
with `stat.S_ISREG(mode) and mode & 0o111`, so the zip entry's mode has to
carry the file-type bits, not just permissions. A bare `0o755` fails `S_ISREG`,
the binary lands `0o644`, and the first run dies with `EPERM`. `twine check`
passes either way; only installing and running catches it.

## Releasing

`.github/workflows/release.yml` runs both builders on a tag and publishes if the
corresponding token is configured. Missing tokens skip that registry rather than
failing the release.

| Secret | Registry |
| --- | --- |
| `NPM_TOKEN` | npm (automation token with publish rights) |
| `PYPI_TOKEN` | PyPI (project or account API token, used as `__token__`) |

`.github/workflows/ci.yml` builds both on every PR and installs and *runs* the
result, because both of the failure modes above pass every static check.
20 changes: 20 additions & 0 deletions packaging/npm/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
# ModelsLab CLI

AI generation and account management from the terminal. One command surface over
the ModelsLab API: image, video, audio, 3D and LLM generation, plus authentication,
billing, wallet, subscriptions and model discovery.

```bash
npm install -g modelslab-cli
modelslab auth login
modelslab generate image --prompt "a lighthouse at dusk" --model flux
```

The package installs a prebuilt binary for your platform as an optional
dependency — nothing is compiled and no install script runs.

Supported: macOS (Intel, Apple Silicon), Linux (x64, arm64), Windows (x64, arm64).

- Docs: https://docs.modelslab.com
- Source: https://github.com/ModelsLab/modelslab-cli
- Other install methods (Homebrew, Scoop, shell): https://modelslab.sh
Loading
Loading