Skip to content

UN-4024 [FEAT] Ship the CLI as a standalone binary for Linux and Apple Silicon - #4

Open
pk-zipstack wants to merge 2 commits into
feat/cli-scaffoldfrom
UN-4024-standalone-binary
Open

UN-4024 [FEAT] Ship the CLI as a standalone binary for Linux and Apple Silicon#4
pk-zipstack wants to merge 2 commits into
feat/cli-scaffoldfrom
UN-4024-standalone-binary

Conversation

@pk-zipstack

@pk-zipstack pk-zipstack commented Sep 1, 2026

Copy link
Copy Markdown

UN-4024

Adds a second way in, for a machine with no Python and nothing to install: one downloadable file carrying its own interpreter. install.sh stays; the binary complements it.

PyInstaller one-file, three unsigned artifacts built per tag — linux-x86_64, linux-arm64, macos-arm64.

  • No Windowsconfig.py narrows the config file with os.fchmod, and 0600 has no Windows equivalent. A permissions decision, not a mechanical patch.
  • No notarisation — the install path is curl, which never sets com.apple.quarantine. PyInstaller's ad-hoc signature is all Apple Silicon needs to exec at all, and it comes for free.

Two changes are the CLI's rather than the build's

--version read importlib.metadata, which a frozen binary has no dist-info to answer from. Passing __version__ removes the failure rather than bundling a .dist-info to compensate — and Click raises RuntimeError there, which __main__ does not catch, so the frozen failure would have been a raw traceback.

load_spec resolved its file through unstract_cli.specs, a package directory with no __init__.py that nothing imports. Frozen, that resolves only because CPython's path finder rescues a module PyInstaller declined, over a directory that exists because a data file happened to be nested in it. It now reads off the package root — the idiom overlay.py already uses, and the path PyInstaller's own resource reader supports.

Notes on the build

The spec file is committed and hand-edited, so regenerating it would drop the reasons. optimize=0 is load-bearing: every derived flag's help text comes from inspect.getdoc() on the published clients, so a -OO build would pass every check and ship an empty --help.

The build job hangs off the existing release workflow with needs: rather than its own tag trigger. Both would attach assets to the same tag, but a tag-triggered run races gh release create, and the loser there fails after PyPI has already published.

Three corrections to the ticket

  1. .github/workflows/release.yml already existed by the time this was picked up — hence the needs: job rather than a new tag-triggered workflow.
  2. Item 2 was not config-only; see the params.py note above.
  3. The glibc rationale was off by one. ubuntu-22.04 gives a 2.35 floor, which covers Debian 12 and Ubuntu 22.04 but not RHEL 9 / Rocky 9 / Amazon Linux 2023 (2.34). Decided: keep the runner and state the floor in the README, pointing those users at install.sh. A manylinux_2_28 container would drop it to 2.28 if that ever becomes a real request.

Verification

Against a real build on linux-x86_64, not reasoned about:

  • 14 MB single file; ldd shows only base OS libraries, no libpython.
  • The archive TOC carries overlay.toml and all four specs/ files as data, and no unstract_cli.specs module.
  • Under env -i with no interpreter on PATH: --version answers, --discover full succeeds, and whisper extract --help / docstudio deployment run --help both carry their spec- and docstring-derived flags.
  • A live TLS round trip to LLMWhisperer with a bogus key returns 401 → exit 3, so certifi resolves from the bundle.
  • A deliberately broken build with datas emptied fails --version outright — which is what makes the CI check worth running.

230 tests pass, ruff clean. The build-binaries job itself cannot be exercised without dispatching a release, which is out of scope here — the ticket says wire it up, not tag.

Known limitations, recorded on the ticket

  • glibc 2.35 floor (above).
  • /tmp mounted noexec breaks one-file builds; uv tool install is the honest answer there.
  • One-file re-extracts ~14 MB per invocation, so startup is slower than the installed console script.
  • Rebuilding a binary for an older tag is not possible in this shape — only "Re-run failed jobs" within a release run. If that comes up, the matrix lifts into a binaries.yml reusable workflow with workflow_dispatch.

🤖 Generated with Claude Code

https://claude.ai/code/session_011heGFrU3Ub85T1oHhe8QFZ

…e Silicon

Adds a second way in, for a machine with no Python and nothing to install:
one downloadable file carrying its own interpreter. `install.sh` stays; the
binary complements it.

PyInstaller one-file, three unsigned artifacts built per tag —
linux-x86_64, linux-arm64, macos-arm64. No Windows, because `config.py`
narrows the config file with `os.fchmod` and 0600 has no Windows
equivalent; that is a permissions decision, not a mechanical patch. No
notarisation, because the install path is `curl`, which never sets
`com.apple.quarantine` — PyInstaller's ad-hoc signature is all Apple
Silicon needs to exec at all, and it comes for free.

Two changes are the CLI's rather than the build's:

`--version` read `importlib.metadata`, which a frozen binary has no
dist-info to answer from. Passing `__version__` removes the failure rather
than bundling a `.dist-info` to compensate — and Click raises `RuntimeError`
there, which `__main__` does not catch, so the frozen failure would have
been a raw traceback.

`load_spec` resolved its file through `unstract_cli.specs`, a package
directory with no `__init__.py` that nothing imports. Frozen, that resolves
only because CPython's path finder rescues a module PyInstaller declined,
over a directory that exists because a data file happened to be nested in
it. It now reads off the package root, which is the idiom `overlay.py`
already uses and the path PyInstaller's own resource reader supports.

The spec file is committed and hand-edited, so regenerating it would drop
the reasons. `optimize=0` is load-bearing: every derived flag's help text
comes from `inspect.getdoc()` on the published clients, so a `-OO` build
would pass every check and ship an empty `--help`.

The build job hangs off the existing release workflow with `needs:` rather
than its own tag trigger. Both would attach assets to the same tag, but a
tag-triggered run races `gh release create`, and the loser there fails
after PyPI has already published.

Verified against a real build on linux-x86_64: 14 MB single file, `ldd`
shows no libpython, and under `env -i` with no interpreter on PATH it
answers `--version`, derives flags from both specs, and completes a live
TLS round trip to LLMWhisperer (401, exit 3) — so certifi resolves from
the bundle. A build with `datas` emptied fails `--version` outright, which
is what makes the CI check worth running.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011heGFrU3Ub85T1oHhe8QFZ
steps:
- uses: actions/checkout@v4
with:
ref: v${{ needs.release-and-publish.outputs.version }}

@pk-zipstack pk-zipstack Sep 1, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pre-release binaries will report the wrong version.

--version now reads unstract_cli.__version__ out of the checked-out tree, and this job builds from ref: v<version>. For a pre_release: true dispatch, release-and-publish runs git checkout -- src/unstract_cli/__init__.py before git tag, so tag v0.2.0rc1 points at a tree whose __init__.py still says the last stable version.

Concrete run, dispatching pre_release: true from 0.1.0: the wheel on PyPI is 0.2.0rc1 (built from the sed'ed worktree, correct), the tag is v0.2.0rc1, and the binary attached to that release answers unstract, version 0.1.0. Anyone exercising an rc binary is testing something that identifies itself as the previous stable release, and a bug report against it names the wrong version.

Either skip this job for pre-releases (if: github.event.inputs.pre_release != 'true'), or tag the pre-release on a commit that actually carries the rc version.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7d6353b. Confirmed the mechanism: pre_release: true reverts __init__.py before git tag, so the tag's tree names the last stable version, and --version reads that tree.

The build job now stamps needs.release-and-publish.outputs.version into __init__.py before installing, and scripts/smoke-binary.sh takes the expected version as its second argument and asserts the binary reports it. Verified both directions against a real build — passes on a match, exits 1 on a mismatch — so a disagreement between the release and the binary attached to it is now a build failure rather than something a user finds.

Comment thread .github/workflows/release.yml
Comment thread .github/workflows/release.yml
Comment thread .github/workflows/release.yml Outdated
Comment thread unstract.spec
…lease depends on it

Four findings from the review, all of which held up when tested.

An rc binary reported the wrong version. `--version` now reads the tree, and
a pre-release reverts `__init__.py` before tagging, so `v0.2.0rc1` is a tag
whose source still names the last stable version: PyPI would say `0.2.0rc1`
and the binary beside it `0.1.0`. The build job now stamps the version it is
releasing, and the smoke check asserts the binary reports it -- the mismatch
is a build failure rather than something a user finds.

The spec was never built until after `uv publish` and `gh release create` had
both run, so a broken one -- a PyInstaller bump, a client re-pin, a data file
added without a `datas` entry -- would have surfaced as a published release
whose advertised download 404s, and the PR notes an older tag cannot be
rebuilt. `ci.yml` now builds it on every pull request.

`collect_data_files` reads the *installed* package, but PyInstaller puts the
entry script's own tree ahead of it on the module search path, so the code was
frozen from `src/` while its data came from site-packages. Confirmed by
diverging the two: the binary reported the version in `src/` and carried the
`overlay.toml` from site-packages. An edited module could therefore ship
beside a stale spec, and the comment claiming the frozen tree was "exactly
what the wheel holds" was wrong. Both halves now come from `src/`.

The help-text check matched a phrase Click had wrapped to the terminal width;
at 80 columns the sentence it was pinning already breaks one word later.
Whitespace is squeezed before matching now, and the needle is shorter.

The checks themselves move into `scripts/smoke-binary.sh` so the pull request
gate and the release run the same ones. Verified against real builds: it
rejects an `-OO` build (passes `--version`, empty derived help), a build with
`datas` emptied, and a binary whose version disagrees with the release.

Left alone: `pip install .` resolving fresh rather than from `uv.lock`. That
matches `ci.yml` and the release job, which both use `uv pip install`, and the
suite already runs a second time against the newest click the pin allows --
the one loose dependency whose internals the CLI reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011heGFrU3Ub85T1oHhe8QFZ
@pk-zipstack
pk-zipstack marked this pull request as ready for review September 1, 2026 17:03
@greptile-apps

greptile-apps Bot commented Sep 1, 2026

Copy link
Copy Markdown

Greptile Summary

Adds PyInstaller-based standalone Linux and Apple Silicon binaries while preserving the existing Python installation path.

  • Builds and smoke-tests a Linux binary in pull-request CI.
  • Builds three platform artifacts after releases, stamps their release version, generates checksums, and uploads them to GitHub.
  • Makes version and bundled-spec resource lookup compatible with frozen execution.
  • Documents binary installation, platform constraints, and local reproduction.

Confidence Score: 5/5

The PR appears safe to merge with no concrete blocking or independently actionable non-blocking defects identified.

The frozen-resource paths, explicit version handling, platform build matrix, and isolated smoke checks form a consistent standalone-binary release path, and no accepted failure remains.

Important Files Changed

Filename Overview
.github/workflows/ci.yml Adds a Linux PyInstaller build and isolated smoke test to pull-request CI.
.github/workflows/release.yml Adds a three-platform post-release binary build, version verification, checksumming, and asset upload.
scripts/smoke-binary.sh Exercises the frozen executable without an interpreter on PATH and verifies bundled specs, generated flags, help text, and optionally the release version.
src/unstract_cli/app.py Supplies Click with the package version directly so frozen execution does not depend on distribution metadata.
src/unstract_cli/core/params.py Resolves vendored specifications relative to the package root for compatibility with normal and frozen resource loaders.
unstract.spec Defines the one-file PyInstaller build, bundled resources, dynamic Rich imports, exclusions, and architecture-sensitive options.
README.md Documents standalone downloads, supported platforms, checksums, compatibility constraints, and local binary builds.

Sequence Diagram

sequenceDiagram
    participant Operator
    participant ReleaseJob as release-and-publish
    participant PyPI
    participant GitHub
    participant Matrix as build-binaries matrix

    Operator->>ReleaseJob: Dispatch release
    ReleaseJob->>ReleaseJob: Compute and verify version
    ReleaseJob->>ReleaseJob: Test and build package
    ReleaseJob->>PyPI: Publish package
    ReleaseJob->>GitHub: Push commit/tag and create release
    ReleaseJob->>Matrix: Provide released version
    Matrix->>GitHub: Checkout release tag
    Matrix->>Matrix: Stamp version and build binary
    Matrix->>Matrix: Run isolated smoke checks
    Matrix->>Matrix: Rename and checksum artifact
    Matrix->>GitHub: Upload binary and checksum
Loading

Reviews (1): Last reviewed commit: "UN-4024 [FIX] Remediate review findings:..." | Re-trigger Greptile

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