-
Notifications
You must be signed in to change notification settings - Fork 0
UN-4024 [FEAT] Ship the CLI as a standalone binary for Linux and Apple Silicon #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feat/cli-scaffold
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -26,6 +26,10 @@ on: | |
| jobs: | ||
| release-and-publish: | ||
| runs-on: ubuntu-latest | ||
| outputs: | ||
| # Consumed by `build-binaries`, which checks out the tag this run created | ||
| # rather than whatever the branch has moved on to. | ||
| version: ${{ steps.version.outputs.version }} | ||
| permissions: | ||
| contents: write | ||
| # Publishing is by PyPI Trusted Publisher, so there is no API token. | ||
|
|
@@ -170,3 +174,81 @@ jobs: | |
| echo "Published ${{ steps.version.outputs.version }} to PyPI with uv publish using Trusted Publishers" | ||
| echo "Release: https://github.com/${{ github.repository }}/releases/tag/v${{ steps.version.outputs.version }}" | ||
| echo "PyPI: https://pypi.org/project/unstract-cli/${{ steps.version.outputs.version }}/" | ||
|
|
||
| # `needs`, not a tag trigger: the release has to exist before anything attaches | ||
| # an asset to it. A tag-push workflow would race the `gh release create` above, | ||
| # and the loser is whichever calls POST /releases second -- which for `gh` | ||
| # means failing after PyPI has already published. | ||
| # | ||
| # PyInstaller does not cross-compile, so one runner per OS and architecture. | ||
| build-binaries: | ||
| needs: [release-and-publish] | ||
| permissions: | ||
| contents: write | ||
| strategy: | ||
| # One platform's toolchain breaking is not a reason to lose the other two. | ||
| fail-fast: false | ||
| matrix: | ||
| include: | ||
| # 22.04 rather than 24.04: a binary's glibc floor is its builder's, and | ||
| # 22.04's 2.35 still covers Ubuntu 22.04 and Debian 12, where 24.04's | ||
| # 2.39 would drop both. | ||
| - runner: ubuntu-22.04 | ||
| asset: unstract-linux-x86_64 | ||
| - runner: ubuntu-22.04-arm | ||
| asset: unstract-linux-arm64 | ||
| - runner: macos-latest # arm64 | ||
| asset: unstract-macos-arm64 | ||
| runs-on: ${{ matrix.runner }} | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| with: | ||
| ref: v${{ needs.release-and-publish.outputs.version }} | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Pre-release binaries will report the wrong version.
Concrete run, dispatching Either skip this job for pre-releases (
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 7d6353b. Confirmed the mechanism: The build job now stamps |
||
|
|
||
| - uses: actions/setup-python@v5 | ||
| with: | ||
| python-version: "3.12" | ||
|
|
||
| # A pre-release reverts `__init__.py` before tagging, so `v0.2.0rc1` is a | ||
| # tag whose tree still names the last stable version. `--version` reads | ||
| # that file, so without this the binary attached to an rc would report a | ||
| # version the release does not have. `-i.bak` because BSD sed on the macOS | ||
| # runner has no bare `-i`. | ||
| - name: Stamp the version being released | ||
| run: | | ||
| sed -i.bak 's/^__version__ = ".*"/__version__ = "${{ needs.release-and-publish.outputs.version }}"/' \ | ||
| src/unstract_cli/__init__.py | ||
| rm -f src/unstract_cli/__init__.py.bak | ||
|
|
||
| # The install is for the dependencies: PyInstaller freezes `unstract_cli` | ||
| # itself from `src/`, which is why the stamp above lands in the binary. | ||
| - name: Install the CLI and PyInstaller | ||
| run: | | ||
| python -m pip install --upgrade pip | ||
| python -m pip install . | ||
|
pk-zipstack marked this conversation as resolved.
|
||
| python -m pip install 'pyinstaller==6.22.2' | ||
|
|
||
| - run: pyinstaller --clean --noconfirm unstract.spec | ||
|
|
||
| # The same checks ci.yml runs on every pull request, plus the version, | ||
| # which only a release knows. | ||
| - name: Check the binary | ||
| run: | | ||
| scripts/smoke-binary.sh dist/unstract \ | ||
| "${{ needs.release-and-publish.outputs.version }}" | ||
|
|
||
| - name: Name and checksum | ||
| run: | | ||
| mv dist/unstract "${{ matrix.asset }}" | ||
| chmod +x "${{ matrix.asset }}" | ||
| shasum -a 256 "${{ matrix.asset }}" > "${{ matrix.asset }}.sha256" | ||
|
|
||
| # The release already exists, so the default token is enough and the App | ||
| # credential never reaches a job that runs a build. `--clobber` makes a | ||
| # re-run idempotent. | ||
| - name: Attach to the release | ||
| env: | ||
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| run: | | ||
| gh release upload "v${{ needs.release-and-publish.outputs.version }}" \ | ||
| "${{ matrix.asset }}" "${{ matrix.asset }}.sha256" --clobber | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| .venv/ | ||
| .venv-freeze/ | ||
| __pycache__/ | ||
| *.egg-info/ | ||
| .pytest_cache/ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| #!/bin/sh | ||
| # Exercise a built `unstract` binary with no interpreter in reach. | ||
| # | ||
| # scripts/smoke-binary.sh dist/unstract [expected-version] | ||
| # | ||
| # Run by ci.yml on every pull request and by release.yml before a binary is | ||
| # attached to a release, so the same checks decide both. A dev box has a Python | ||
| # that would answer an import the bundle is missing, which is why every command | ||
| # below runs under `env -i` with an empty PATH. | ||
| set -eu | ||
|
|
||
| BIN=$(cd "$(dirname "$1")" && pwd)/$(basename "$1") | ||
| EXPECTED_VERSION="${2:-}" | ||
|
|
||
| mkdir -p /tmp/emptybin | ||
| run() { env -i PATH=/tmp/emptybin HOME="$HOME" "$BIN" "$@"; } | ||
|
|
||
| # Not a trivial path: importing the command modules applies the `@spec_options` | ||
| # decorators, which read `overlay.toml` and both vendored specs before Click | ||
| # parses anything. A bundle missing its data files fails here. | ||
| run --version | ||
| run --discover full >/dev/null | ||
|
|
||
| # Click wraps help text to the terminal width, so a phrase can arrive split | ||
| # across lines; squeeze the whitespace rather than pin the wrapping. | ||
| help_text() { run "$@" --help | tr -s '[:space:]' ' '; } | ||
|
|
||
| # One derived flag per vendored spec, proving each was reachable... | ||
| help_text whisper extract | grep -q -- '--add-line-nos' | ||
| help_text docstudio deployment run | grep -q -- '--hitl-packet-id' | ||
| # ...and one help string, which comes from the published client's docstring | ||
| # rather than from the spec. A build made with `-OO` passes everything above and | ||
| # fails only this. | ||
| help_text whisper extract | grep -q 'Adds line numbers' | ||
|
|
||
| # The release job stamps the version it published; a binary that disagrees with | ||
| # the release it is attached to is worse than no binary. | ||
| if [ -n "$EXPECTED_VERSION" ]; then | ||
| run --version | grep -q "$EXPECTED_VERSION" | ||
| fi | ||
|
|
||
| echo "OK $(basename "$BIN")" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| # -*- mode: python ; coding: utf-8 -*- | ||
| """One-file build of the `unstract` CLI. | ||
|
|
||
| Generated once with | ||
|
|
||
| pyinstaller --onefile --console --name unstract src/unstract_cli/__main__.py | ||
|
|
||
| and hand-edited since. This file is the build, not that command line: the | ||
| options below are decisions, and a regenerated spec would drop them silently. | ||
| """ | ||
|
|
||
| # The packaged files that are read through `importlib.resources` -- `overlay.toml` | ||
| # and the vendored specs -- and read at *import* time, by the `@spec_options` | ||
| # decorators the command modules apply at module scope. They are data, not | ||
| # modules, so nothing puts them in the PYZ, and a bundle without them builds | ||
| # clean and then fails on every invocation. | ||
| # | ||
| # Taken from `src/` rather than from `collect_data_files("unstract_cli")`, which | ||
| # reads the *installed* package. PyInstaller prepends the entry script's parent | ||
| # package directory to the module search path, so the code is frozen from `src/` | ||
| # either way; sourcing the data from site-packages would let an edited module | ||
| # ship beside a stale spec. One tree decides both. | ||
| # | ||
| # A new data file needs a line here. `ci.yml` builds this spec on every pull | ||
| # request, so one that is read at import time fails the gate rather than a | ||
| # release. | ||
| datas = [ | ||
| ("src/unstract_cli/overlay.toml", "unstract_cli"), | ||
| ("src/unstract_cli/specs", "unstract_cli/specs"), | ||
| ] | ||
|
|
||
| hiddenimports = [ | ||
| # `unstract.clone.report.CloneReport.render` imports these inside the | ||
| # function, behind `except ImportError: return self._render_plain()`. The | ||
| # module graph does follow function-level imports, but a miss here degrades | ||
| # `unstract clone`'s table to plain text without failing anything, so the | ||
| # dependency is stated rather than inferred. | ||
| "rich.console", | ||
| "rich.table", | ||
| ] | ||
|
|
||
| # A local build runs in a `.[dev]` venv, so the test and lint tooling is on the | ||
| # path even though nothing reaches it from the entry point. CI installs only the | ||
| # runtime dependencies, where these are no-ops -- they keep the two builds the | ||
| # same size rather than being load-bearing. `unittest` is deliberately absent: | ||
| # the size it saves is small, and libraries reach for `unittest.mock` in | ||
| # surprising places. | ||
| excludes = [ | ||
| "pytest", | ||
| "_pytest", | ||
| "pluggy", | ||
| "iniconfig", | ||
| "ruff", | ||
| "setuptools", | ||
| "pkg_resources", | ||
| "tkinter", | ||
| ] | ||
|
|
||
| a = Analysis( | ||
| ["src/unstract_cli/__main__.py"], | ||
|
pk-zipstack marked this conversation as resolved.
|
||
| pathex=[], | ||
| binaries=[], | ||
| datas=datas, | ||
| hiddenimports=hiddenimports, | ||
| hookspath=[], | ||
| hooksconfig={}, | ||
| runtime_hooks=[], | ||
| excludes=excludes, | ||
| noarchive=False, | ||
| # Not 1 or 2, and never build with PYTHONOPTIMIZE set. Every derived flag's | ||
| # help text comes from `inspect.getdoc()` on the published clients' methods | ||
| # -- the specs carry no parameter descriptions -- so stripping docstrings | ||
| # empties `--help` across the whole generated surface without failing a | ||
| # single check. | ||
| optimize=0, | ||
| ) | ||
|
|
||
| pyz = PYZ(a.pure) | ||
|
|
||
| exe = EXE( | ||
| pyz, | ||
| a.scripts, | ||
| # One file: the binaries and the data are folded into the executable rather | ||
| # than collected beside it, so there is no COLLECT and nothing to unpack. | ||
| a.binaries, | ||
| a.datas, | ||
| [], | ||
| name="unstract", | ||
| debug=False, | ||
| bootloader_ignore_signals=False, | ||
| # Stripping invalidates the ad-hoc signature an arm64 macOS binary needs in | ||
| # order to run at all, and occasionally produces unloadable shared objects | ||
| # on Linux. It saves a couple of megabytes out of twenty. | ||
| strip=False, | ||
| # UPX is unusable on macOS arm64, and on Linux it buys size back by adding | ||
| # decompression to every start and by looking like packed malware to EDR. | ||
| upx=False, | ||
| upx_exclude=[], | ||
| runtime_tmpdir=None, | ||
| console=True, | ||
| disable_windowed_traceback=False, | ||
| argv_emulation=False, | ||
| # The building interpreter's architecture. The runners are native, and no | ||
| # universal binary is shipped. | ||
| target_arch=None, | ||
| # Unsigned by design. PyInstaller still applies the ad-hoc signature Apple | ||
| # Silicon requires to execute a Mach-O at all; what is absent is a Developer | ||
| # ID signature and notarisation, which is why a browser download needs its | ||
| # quarantine attribute cleared. | ||
| codesign_identity=None, | ||
| entitlements_file=None, | ||
| ) | ||
Uh oh!
There was an error while loading. Please reload this page.