UN-4024 [FEAT] Ship the CLI as a standalone binary for Linux and Apple Silicon - #4
UN-4024 [FEAT] Ship the CLI as a standalone binary for Linux and Apple Silicon#4pk-zipstack wants to merge 2 commits into
Conversation
…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 }} |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
…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
|
| 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
Reviews (1): Last reviewed commit: "UN-4024 [FIX] Remediate review findings:..." | Re-trigger Greptile
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.shstays; the binary complements it.PyInstaller one-file, three unsigned artifacts built per tag —
linux-x86_64,linux-arm64,macos-arm64.config.pynarrows the config file withos.fchmod, and0600has no Windows equivalent. A permissions decision, not a mechanical patch.curl, which never setscom.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
--versionreadimportlib.metadata, which a frozen binary has no dist-info to answer from. Passing__version__removes the failure rather than bundling a.dist-infoto compensate — and Click raisesRuntimeErrorthere, which__main__does not catch, so the frozen failure would have been a raw traceback.load_specresolved its file throughunstract_cli.specs, a package directory with no__init__.pythat 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 idiomoverlay.pyalready 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=0is load-bearing: every derived flag's help text comes frominspect.getdoc()on the published clients, so a-OObuild 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 racesgh release create, and the loser there fails after PyPI has already published.Three corrections to the ticket
.github/workflows/release.ymlalready existed by the time this was picked up — hence theneeds:job rather than a new tag-triggered workflow.params.pynote above.ubuntu-22.04gives 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 atinstall.sh. Amanylinux_2_28container 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:lddshows only base OS libraries, no libpython.overlay.tomland all fourspecs/files as data, and nounstract_cli.specsmodule.env -iwith no interpreter onPATH:--versionanswers,--discover fullsucceeds, andwhisper extract --help/docstudio deployment run --helpboth carry their spec- and docstring-derived flags.datasemptied fails--versionoutright — which is what makes the CI check worth running.230 tests pass, ruff clean. The
build-binariesjob 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
/tmpmountednoexecbreaks one-file builds;uv tool installis the honest answer there.binaries.ymlreusable workflow withworkflow_dispatch.🤖 Generated with Claude Code
https://claude.ai/code/session_011heGFrU3Ub85T1oHhe8QFZ