diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 790aff0..e594137 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,11 @@ on: branches: [main] pull_request: +# Every job only reads the repository: its checkout, and rust-sys-abi's +# release download. +permissions: + contents: read + # Self-hosted runner safety: a push only happens with write access, but a # pull_request can come from an external fork. Gate every job so PR runs execute # on the laptop runner ONLY for branches pushed into THIS repo (i.e. by someone @@ -73,3 +78,18 @@ jobs: run: cargo test env: BASERT_LIB_DIR: ${{ github.workspace }}/build + + packaging: + name: packaging scripts (tests) + # GitHub-hosted, so it needs no fork guard. linux-arm64 is one of the + # platforms the npm launcher tests run on. + runs-on: ubuntu-24.04-arm + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + # publish.yml runs these scripts only after a release; their engine + # staging, link check and npm launcher are tested here on synthetic + # bundles. + - run: python3 tests/scripts/test_packaging.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..1ef38e5 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,559 @@ +name: Publish packages + +# Puts each BaseRT release on PyPI (basert), npm (@basecompute/basert and its +# engine packages) and crates.io (the basert-* CLI crates, baseRT-sys and +# baseRT), and opens the Homebrew formula PR on basecompute/homebrew-tap, by +# repackaging the engine bundles the release carries: nothing here compiles +# the engine. The scripts are in packaging/. +# +# It runs when a release is published (the engine's release pipeline creates +# it as a draft at the commit that holds the release's source, attaches both +# engine bundles, then publishes it) and by hand for a tag. Every run builds +# every package and installs each on macOS and on Linux arm64. A run uploads +# only when all of these hold, and is otherwise a dry run: +# - the repository variable PUBLISH_ENABLED is "true" and dry_run is not ticked; +# - the release is published: not a draft, not a pre-release; +# - this run's commit is the one the tag names, so the packages hold that +# release's source (a manual run from a branch dry-runs). +# Each upload job first asks its registry what it has, so a rerun finishes +# what an interrupted run left and repeats nothing. +# +# The Linux wheel and npm engine package are built and tested on every run +# but uploaded only when PUBLISH_LINUX is "true" too: the Linux engine's +# licence grants use on Apple Silicon only. Until then the npm package depends +# on the macOS engine package alone. (The Homebrew formula keeps its Linux URL: +# it points at the bundle the release already publishes, the one install.sh +# fetches.) +# +# Setup, once: environments pypi, npm, crates and homebrew, each limited to +# v* tags; PyPI trusted publisher for basert (this repository, publish.yml, +# environment pypi); secrets NPM_TOKEN (npm), CARGO_REGISTRY_TOKEN (crates) +# and HOMEBREW_TAP_TOKEN (homebrew: Contents and Pull requests write on the +# tap); then PUBLISH_ENABLED. +# +# Actions are pinned to commits and tools to exact versions: the upload jobs +# hold publishing rights. + +on: + release: + types: [released] + workflow_dispatch: + inputs: + tag: + description: Release tag to package (vX.Y.Z); run from that tag to upload + required: true + dry_run: + description: Build and test everything, upload nothing + type: boolean + default: true + +permissions: + contents: read + +concurrency: + group: publish-${{ github.event.release.tag_name || inputs.tag }} + cancel-in-progress: false + +env: + TAG: ${{ github.event.release.tag_name || inputs.tag }} + # setup-python must find this exact release for macOS as well as Linux, and + # its macOS builds stop when a Python line goes security-only: 3.12 has none + # past 3.12.10. So the pin stays on a line still getting bugfix releases. + PYTHON_VERSION: "3.14.9" + NODE_VERSION: "24.21.0" + RUST_VERSION: "1.99.0" + TAP_REPO: basecompute/homebrew-tap + +jobs: + plan: + name: Plan + runs-on: ubuntu-24.04 + outputs: + version: ${{ steps.plan.outputs.version }} + upload: ${{ steps.plan.outputs.upload }} + linux: ${{ steps.plan.outputs.linux }} + pypi: ${{ steps.registries.outputs.pypi }} + npm: ${{ steps.registries.outputs.npm }} + crates: ${{ steps.registries.outputs.crates }} + brew: ${{ steps.registries.outputs.brew }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Upload or dry run + id: plan + env: + GH_TOKEN: ${{ github.token }} + PUBLISH_ENABLED: ${{ vars.PUBLISH_ENABLED }} + PUBLISH_LINUX: ${{ vars.PUBLISH_LINUX }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + case "$TAG" in + v[0-9]*.[0-9]*.[0-9]*) ;; + *) echo "::error::$TAG is not a release tag (vX.Y.Z)"; exit 1 ;; + esac + V="${TAG#v}" + # The packages take their versions from these files, so each must + # say the tag's. + python3 - "$V" <<'EOF' + import json, re, sys, tomllib + from pathlib import Path + + def toml(path, *keys): + data = tomllib.loads(Path(path).read_text()) + for key in keys: + data = data[key] + return data + + def find(path, pattern): + m = re.search(pattern, Path(path).read_text(), re.M) + return m.group(1) if m else "nothing" + + header = "include/baseRT/baseRT.h" + found = { + "base-convert/Cargo.toml": toml("base-convert/Cargo.toml", "workspace", "package", "version"), + "bindings/rust/baseRT-sys/Cargo.toml": toml("bindings/rust/baseRT-sys/Cargo.toml", "package", "version"), + "bindings/rust/baseRT/Cargo.toml": toml("bindings/rust/baseRT/Cargo.toml", "package", "version"), + "bindings/python/baseRT/__init__.py": find("bindings/python/baseRT/__init__.py", r'^__version__ = "(.*)"$'), + "bindings/node/package.json": json.loads(Path("bindings/node/package.json").read_text())["version"], + header: ".".join(find(header, rf"^#define BASERT_VERSION_{p} (\d+)$") for p in ("MAJOR", "MINOR", "PATCH")), + } + wrong = [f"{path} says {v}" for path, v in found.items() if v != sys.argv[1]] + if wrong: + print(f"::error::this tree is not at {sys.argv[1]}: " + "; ".join(wrong)) + sys.exit(1) + EOF + echo "version=$V" >> "$GITHUB_OUTPUT" + if [ "${PUBLISH_LINUX:-}" = true ]; then linux=true; else linux=false; fi + echo "linux=$linux" >> "$GITHUB_OUTPUT" + # A missing release stops the run here, with gh's message. + state=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft,isPrerelease \ + --jq 'if .isDraft then "a draft" elif .isPrerelease then "a pre-release" else "published" end') + # The commit the tag names: the peeled line for an annotated tag, else the tag's own. + tagged=$(git ls-remote "https://github.com/$GITHUB_REPOSITORY" "refs/tags/$TAG" "refs/tags/$TAG^{}" \ + | sort -k2 | tail -n 1 | cut -f1) + why="" + if [ "${PUBLISH_ENABLED:-}" != true ]; then why="PUBLISH_ENABLED is ${PUBLISH_ENABLED:-unset}" + elif [ "${DRY_RUN:-}" = true ]; then why="dry_run is ticked" + elif [ "$state" != published ]; then why="$TAG is $state" + elif [ "$tagged" != "$GITHUB_SHA" ]; then why="this run is at $GITHUB_SHA, and $TAG names ${tagged:-no commit}" + fi + if [ -z "$why" ]; then + echo "upload=true" >> "$GITHUB_OUTPUT" + echo "Uploading BaseRT $V wherever it is missing." + [ "$linux" = true ] || echo "::notice::PUBLISH_LINUX is not \"true\": the Linux wheel and npm engine package are tested, not uploaded." + else + echo "upload=false" >> "$GITHUB_OUTPUT" + echo "::notice::Dry run ($why): every package is built and tested, none uploaded." + fi + + - name: What each registry has + id: registries + env: + V: ${{ steps.plan.outputs.version }} + UPLOAD: ${{ steps.plan.outputs.upload }} + LINUX: ${{ steps.plan.outputs.linux }} + run: | + set -euo pipefail + set -f # the file name patterns below are matched, not expanded + agent="basert-publish (github.com/$GITHUB_REPOSITORY)" + has() { curl -sfo /dev/null -A "$agent" "$1"; } + # need : its jobs run unless this run uploads and + # the check finds everything there. A dry run builds and tests all. + need() { + name=$1; shift + if [ "$UPLOAD" != true ]; then + echo "$name=true" >> "$GITHUB_OUTPUT"; echo "$name: dry run, $V is built and tested" + elif "$@"; then + echo "$name=false" >> "$GITHUB_OUTPUT"; echo "$name: $V is there already" + else + echo "$name=true" >> "$GITHUB_OUTPUT"; echo "$name: $V will be published" + fi + } + # Every platform wheel this run uploads, not just the version: a run + # that uploaded one wheel and then failed leaves the version on PyPI. + # The platform tags come from the build, so they're matched. + wheels="basert-$V-py3-none-macosx_*_arm64.whl" + engines="basert-darwin-arm64" + if [ "$LINUX" = true ]; then + wheels="$wheels basert-$V-py3-none-manylinux_*_aarch64.whl" + engines="$engines basert-linux-arm64-cuda" + fi + pypi_has() { + files=$(curl -sf -A "$agent" "https://pypi.org/pypi/basert/$V/json" | jq -r '.urls[].filename') || return 1 + for want in $wheels; do + found=no + for f in $files; do case "$f" in $want) found=yes ;; esac; done + [ "$found" = yes ] || return 1 + done + } + npm_has() { + for pkg in $engines basert; do has "https://registry.npmjs.org/@basecompute%2F$pkg/$V" || return 1; done + } + # The last crate of each workspace goes up last, so both present + # means the whole set is. + crates_has() { + has "https://crates.io/api/v1/crates/basert-cli/$V" && has "https://crates.io/api/v1/crates/baseRT/$V" + } + tap_has() { + curl -sf "https://raw.githubusercontent.com/$TAP_REPO/HEAD/Formula/basert.rb" | grep -q "^ version \"$V\"$" + } + need pypi pypi_has + need npm npm_has + need crates crates_has + need brew tap_has + + build: + name: Build packages + needs: plan + runs-on: ubuntu-24.04 + env: + V: ${{ needs.plan.outputs.version }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: ${{ env.PYTHON_VERSION }} + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Test the packaging scripts + run: python3 tests/scripts/test_packaging.py + + - name: Download the release bundles + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern 'basert-engine-*' --dir bundles + ls -l bundles + (cd bundles && sha256sum -c ./*.sha256) + + - name: Homebrew formula + run: packaging/update-formula.sh "$V" bundles out/formula + + - name: PyPI wheels + run: | + python3 -m pip install --quiet build==1.6.1 wheel==0.48.0 twine==7.0.0 + for b in bundles/*.tar.gz; do packaging/python/build-wheel.sh "$b" out/wheels; done + python3 -m twine check --strict out/wheels/*.whl + + - name: npm packages + env: + LINUX: ${{ needs.plan.outputs.linux }} + run: | + # Both engine packages are built for the tests below; the main + # package depends only on the ones this run may publish. + engines=basert-darwin-arm64 + [ "$LINUX" != true ] || engines="$engines,basert-linux-arm64-cuda" + node packaging/npm/build-packages.mjs bundles out/npm --engines "$engines" + + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: packages + path: out/ + if-no-files-found: error + + test-macos: + name: Test on macOS + needs: build + runs-on: macos-15 + steps: + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: packages + path: out + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: ${{ env.PYTHON_VERSION }} + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: ${{ env.NODE_VERSION }} + + # `basert serve --help` below: serving is what the READMEs lead with, + # and nothing else here runs that command. (A dropped library name is + # caught earlier, by check_engine_links.py when the engine is staged, + # and by the linked program after the installs.) + - name: pip install basert + run: | + python3 -m venv venv + venv/bin/pip install --quiet out/wheels/*macosx*.whl + venv/bin/basert --version + venv/bin/basert chat --help | head -n 3 # the launcher finds basert-chat + venv/bin/basert serve --help | head -n 3 + venv/bin/python -c "import ctypes, baseRT; f = baseRT._get_lib().baseRT_version_string; f.restype = ctypes.c_char_p; print('engine', f().decode())" + + - name: npm install @basecompute/basert + run: | + mkdir npm-test && cd npm-test && npm init -y > /dev/null + npm install --no-audit --no-fund ../out/npm/basecompute-basert-darwin-arm64-*.tgz ../out/npm/basecompute-basert-[0-9]*.tgz + npx basert --version + npx basert serve --help | head -n 3 + # A missing model fails inside the engine, which proves the library loaded. + node -e ' + const { BaseRTModel } = require("@basecompute/basert"); + try { new BaseRTModel("/nonexistent.base"); } catch (e) { + if (/Cannot find libbaseRT/.test(e.message)) throw e; + console.log("engine loaded:", e.message.split("\n")[0]); + }' + + # A Rust or C program linked against an installed engine (BASERT_LIB_DIR + # pointed at it) asks at startup for the name the library records, + # libbaseRT.0.dylib, not the file it was linked through: build one + # against each and run it. + - name: Link a program against the installed engines + run: | + printf '#include \nconst char *baseRT_version_string(void);\nint main(void) { return puts(baseRT_version_string()) < 0; }\n' > consumer.c + for engine in "$(venv/bin/python -c 'import baseRT, os; print(os.path.dirname(baseRT._find_library()))')" \ + "$PWD/npm-test/node_modules/@basecompute/basert-darwin-arm64/engine"; do + cc consumer.c -o consumer -L"$engine" -lbaseRT -Wl,-rpath,"$engine" + ./consumer + done + + # The formula installs the release's bundle from its public URL, so + # this needs the release published. + - name: brew install basert + env: + HOMEBREW_NO_AUTO_UPDATE: "1" + run: | + # A throwaway local tap stands in for basecompute/homebrew-tap. + brew tap-new --no-git basecompute/publish-test + cp out/formula/basert.rb "$(brew --repository basecompute/publish-test)/Formula/" + brew style basecompute/publish-test/basert + brew install basecompute/publish-test/basert + brew test basecompute/publish-test/basert + basert chat --help | head -n 3 + basert serve --help | head -n 3 + + test-linux: + name: Test on Linux arm64 + needs: build + runs-on: ubuntu-24.04-arm + steps: + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: packages + path: out + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: ${{ env.PYTHON_VERSION }} + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: ${{ env.NODE_VERSION }} + + # No GPU on this runner, so the CUDA engine can't load; check that each + # package installs, runs the launcher, and points the bindings at the + # bundled library. + - name: pip install basert + run: | + python3 -m venv venv + venv/bin/pip install --quiet out/wheels/*manylinux*.whl + venv/bin/basert --version + venv/bin/basert serve --help | head -n 3 + venv/bin/python -c "import baseRT; p = baseRT._find_library(); assert '/_engine/' in p, p; print(p)" + + - name: npm install @basecompute/basert + run: | + mkdir npm-test && cd npm-test && npm init -y > /dev/null + npm install --no-audit --no-fund ../out/npm/basecompute-basert-linux-arm64-cuda-*.tgz ../out/npm/basecompute-basert-[0-9]*.tgz + npx basert --version + npx basert serve --help | head -n 3 + + # As on macOS, but nothing here can run the CUDA engine: link past the + # driver libraries it needs and check the engine's own name, the SONAME + # libbaseRT.so.0 a linked program records, resolves in the package. + - name: Link a program against the installed engines + run: | + printf 'const char *baseRT_version_string(void);\nint main(void) { return baseRT_version_string() == 0; }\n' > consumer.c + for engine in "$(venv/bin/python -c 'import baseRT, os; print(os.path.dirname(baseRT._find_library()))')" \ + "$PWD/npm-test/node_modules/@basecompute/basert-linux-arm64-cuda/engine"; do + cc consumer.c -o consumer -L"$engine" -lbaseRT -Wl,-rpath,"$engine" -Wl,--allow-shlib-undefined + ldd ./consumer | grep libbaseRT + ldd ./consumer | grep -q "libbaseRT.so.0 => $engine/libbaseRT.so.0" + done + + crates-check: + name: Verify crates + needs: plan + if: needs.plan.outputs.crates == 'true' + runs-on: ubuntu-24.04-arm + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # master, 2026-10-09 + with: + toolchain: ${{ env.RUST_VERSION }} + - name: cargo publish --dry-run + run: | + python3 packaging/crates/stage.py "$RUNNER_TEMP/crates" + (cd "$RUNNER_TEMP/crates/cli" && cargo publish --workspace --dry-run) + # baseRT-sys only needs its library directory to exist to package. + mkdir -p "$RUNNER_TEMP/engine" + (cd "$RUNNER_TEMP/crates/bindings" && BASERT_LIB_DIR="$RUNNER_TEMP/engine" cargo publish --workspace --dry-run) + + # --- uploads: only when the plan says so ----------------------------------- + + pypi: + name: Publish to PyPI + needs: [plan, test-macos, test-linux] + if: needs.plan.outputs.upload == 'true' && needs.plan.outputs.pypi == 'true' + runs-on: ubuntu-24.04 + environment: + name: pypi + url: https://pypi.org/project/basert/${{ needs.plan.outputs.version }}/ + permissions: + id-token: write # trusted publishing, and the attestations + steps: + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: packages + path: out + - name: Leave out the Linux wheel unless PUBLISH_LINUX + if: needs.plan.outputs.linux != 'true' + run: rm -v out/wheels/*manylinux*.whl + - uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3 + with: + packages-dir: out/wheels/ + # PyPI never accepts a file name twice, so a rerun after a partial + # upload would stop at the first wheel already there. Step over it. + skip-existing: true + + npm: + name: Publish to npm + needs: [plan, test-macos, test-linux] + if: needs.plan.outputs.upload == 'true' && needs.plan.outputs.npm == 'true' + runs-on: ubuntu-24.04 + environment: + name: npm + url: https://www.npmjs.com/package/@basecompute/basert/v/${{ needs.plan.outputs.version }} + permissions: + id-token: write # provenance + steps: + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: packages + path: out + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: ${{ env.NODE_VERSION }} + registry-url: https://registry.npmjs.org + - name: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + V: ${{ needs.plan.outputs.version }} + LINUX: ${{ needs.plan.outputs.linux }} + run: | + set -euo pipefail + # Engine packages first, so the main package's optional dependencies + # already exist when it appears. Skip any a previous run published. + engines=basert-darwin-arm64 + [ "$LINUX" != true ] || engines="$engines basert-linux-arm64-cuda" + for pkg in $engines basert; do + if npm view "@basecompute/$pkg@$V" version > /dev/null 2>&1; then + echo "@basecompute/$pkg@$V is on npm already" + else + npm publish "out/npm/basecompute-$pkg-$V.tgz" --access public --provenance + fi + done + + crates: + name: Publish to crates.io + needs: [plan, crates-check, test-macos, test-linux] + if: needs.plan.outputs.upload == 'true' && needs.plan.outputs.crates == 'true' + runs-on: ubuntu-24.04-arm + environment: + name: crates + url: https://crates.io/crates/basert-cli/${{ needs.plan.outputs.version }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # master, 2026-10-09 + with: + toolchain: ${{ env.RUST_VERSION }} + - name: cargo publish + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + V: ${{ needs.plan.outputs.version }} + run: | + set -euo pipefail + python3 packaging/crates/stage.py "$RUNNER_TEMP/crates" + mkdir -p "$RUNNER_TEMP/engine" + export BASERT_LIB_DIR="$RUNNER_TEMP/engine" + # One crate at a time, dependencies first, skipping any a previous + # run published. `cargo publish` waits for each to reach the index. + # --no-verify: crates-check built these same crates from this same + # commit, and verifying again would run every dependency's build + # script with CARGO_REGISTRY_TOKEN in the environment. + publish() { + ws=$1; shift + for crate in "$@"; do + if curl -sfo /dev/null -A "basert-publish (github.com/$GITHUB_REPOSITORY)" \ + "https://crates.io/api/v1/crates/$crate/$V"; then + echo "$crate $V is on crates.io already" + else + (cd "$RUNNER_TEMP/crates/$ws" && cargo publish --no-verify -p "$crate") + fi + done + } + publish cli basert-format basert-quant basert-readers basert-sign \ + basert-hub basert-awq basert-arch basert-cli + publish bindings baseRT-sys baseRT + + homebrew: + name: Open the Homebrew formula PR + needs: [plan, test-macos] + if: needs.plan.outputs.upload == 'true' && needs.plan.outputs.brew == 'true' + runs-on: ubuntu-24.04 + environment: homebrew + steps: + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: packages + path: out + # The formula reaches `brew install` users when this PR is merged on the + # tap, so a person sees every Homebrew release. Each step is safe to + # repeat: a rerun finishes a branch pushed without a PR, a PR closed + # unmerged, or a PR whose checksums went stale because the release's + # bundles were uploaded again (--clobber). + - name: Open a pull request on the tap + env: + # A token for the tap alone, with Contents and Pull requests write; + # this repository's own token cannot write there. + GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + V: ${{ needs.plan.outputs.version }} + run: | + set -euo pipefail + branch="formula/basert-$V" + gh auth setup-git # plain `git push` below authenticates with GH_TOKEN + gh repo clone "$TAP_REPO" tap -- --depth 1 + cd tap + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + if cmp -s ../out/formula/basert.rb Formula/basert.rb; then + echo "The tap's default branch has basert $V already." + exit 0 + fi + if git ls-remote --exit-code --heads origin "$branch" > /dev/null; then + git fetch --depth 1 origin "$branch:$branch" + git switch "$branch" + else + git switch -c "$branch" + fi + mkdir -p Formula + cp ../out/formula/basert.rb Formula/basert.rb + git add Formula/basert.rb + git diff --cached --quiet || git commit -m "basert $V" + git push origin "$branch" + open=$(gh pr list --repo "$TAP_REPO" --head "$branch" --state open --json url --jq '.[0].url') + if [ -n "$open" ]; then + echo "The PR is open already: $open" + else + gh pr create --repo "$TAP_REPO" --head "$branch" --title "basert $V" \ + --body "Updates the formula to the v$V release bundles (version and checksums only), from basecompute/baseRT's publish workflow, which installed and tested it on macOS. Merging it makes $V what \`brew install\` and \`brew upgrade\` give." + fi diff --git a/packaging/check_engine_links.py b/packaging/check_engine_links.py new file mode 100644 index 0000000..16ee5d0 --- /dev/null +++ b/packaging/check_engine_links.py @@ -0,0 +1,161 @@ +"""Check that a staged engine folder loads on its own. + + python3 packaging/check_engine_links.py + python3 packaging/check_engine_links.py --own-name + +Wheels and npm tarballs cannot carry symlinks, so stage-engine.sh ships the +engine library as real files: under the name the bindings load +(libbaseRT.dylib / libbaseRT.so), and under the name the library records for +itself (its install name, @rpath/libbaseRT.0.dylib, or its SONAME, +libbaseRT.so.0), which is what a program linked against it asks for at run +time. Every other libbaseRT name is dropped. This fails, naming each, if + +* any Mach-O or ELF file in the folder needs a library of ours the folder + does not hold: a libbaseRT of any name, or on macOS anything loaded through + @rpath, @loader_path or @executable_path; or +* a library in the folder records a name for itself the folder does not + hold, so a program linked against it after installation (a Rust or C + consumer pointed at the folder with BASERT_LIB_DIR) would build and then + not start. + +System libraries (libSystem, libcuda, libcublas) are the machine's to +provide. --own-name prints the name a library records for itself (nothing if +it records none); stage-engine.sh uses it to place that copy. + +The load commands are read from the files themselves, not through otool or +ldd, so one runner checks both platforms' bundles. +""" + +from __future__ import annotations + +import struct +import sys +from pathlib import Path + +MH_MAGIC_64 = 0xFEEDFACF +# LC_LOAD_DYLIB, LC_LOAD_WEAK_DYLIB, LC_REEXPORT_DYLIB, LC_LAZY_LOAD_DYLIB, +# LC_LOAD_UPWARD_DYLIB: what a file needs. LC_ID_DYLIB is a library's own name. +MACHO_LOADS = {0xC, 0x80000018, 0x8000001F, 0x20, 0x80000023} +LC_ID_DYLIB = 0xD +ELF_MAGIC = b"\x7fELF" +PT_LOAD, PT_DYNAMIC = 1, 2 +DT_NULL, DT_NEEDED, DT_STRTAB, DT_SONAME = 0, 1, 5, 14 + + +def _cstr(data: bytes, off: int) -> str: + return data[off : data.index(b"\0", off)].decode() + + +def macho_names(data: bytes) -> tuple[list[str], str | None]: + """(the libraries a Mach-O file loads, its own install name or None).""" + ncmds = struct.unpack_from(" tuple[list[str], str | None]: + """(an ELF file's DT_NEEDED libraries, its DT_SONAME or None).""" + if data[4] != 2 or data[5] != 1: + raise ValueError("not a 64-bit little-endian ELF") + phoff = struct.unpack_from(" tuple[list[str], str | None] | None: + """What a Mach-O or ELF file loads and the name it records for itself; None for any other file.""" + data = f.read_bytes() + if len(data) >= 32 and struct.unpack_from(" bool: + return dep.rsplit("/", 1)[-1].startswith("libbaseRT") or dep.startswith("@") + + +def missing_links(engine: Path) -> list[tuple[str, str]]: + """(file, library) for each library of ours a file needs that `engine` lacks.""" + missing = [] + for f in sorted(engine.iterdir()): + if not f.is_file() or f.is_symlink(): + continue + names = link_names(f) + for dep in names[0] if names else []: + name = dep.rsplit("/", 1)[-1] + if ours(dep) and not (engine / name).is_file(): + missing.append((f.name, name)) + return missing + + +def missing_own_names(engine: Path) -> list[tuple[str, str]]: + """(library, name) for each library of ours whose own recorded name `engine` lacks.""" + missing = [] + for f in sorted(engine.iterdir()): + if not f.is_file() or f.is_symlink(): + continue + names = link_names(f) + own = names[1] if names else None + if own is not None and ours(own) and not (engine / own.rsplit("/", 1)[-1]).is_file(): + missing.append((f.name, own.rsplit("/", 1)[-1])) + return missing + + +def main() -> None: + if len(sys.argv) == 3 and sys.argv[1] == "--own-name": + names = link_names(Path(sys.argv[2])) + if names and names[1]: + print(names[1].rsplit("/", 1)[-1]) + return + if len(sys.argv) != 2: + sys.exit(__doc__) + engine = Path(sys.argv[1]) + missing = missing_links(engine) + for f, name in missing: + print(f"{engine}: {f} needs {name}, which is not in the folder", file=sys.stderr) + unnamed = missing_own_names(engine) + for f, name in unnamed: + print(f"{engine}: {f} calls itself {name}, which is not in the folder: a program linked against it would not start", file=sys.stderr) + if missing or unnamed: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/packaging/crates/stage.py b/packaging/crates/stage.py new file mode 100644 index 0000000..ea28023 --- /dev/null +++ b/packaging/crates/stage.py @@ -0,0 +1,194 @@ +"""Stage the Rust crates for crates.io in a scratch folder. + + python3 packaging/crates/stage.py + +Writes two cargo workspaces, each publishable with +`cargo publish --workspace` (cargo 1.90+, which also orders the crates): + + /cli the basert CLI (base-convert/) and its 7 library crates + /bindings the Rust bindings (bindings/rust/): baseRT-sys, baseRT + +Both source folders keep their manifests as they are for development, so the +changes crates.io needs are made to copies here: + +* Crate names get a `basert-` prefix (`base-format` -> `basert-format`) so they + don't claim generic names on the shared registry. The CLI package becomes + `basert-cli`, since crates.io treats `basert` and the bindings' `baseRT` as + the same name. Dependency keys are unchanged (`package = "basert-format"`), + and each library keeps its name (`[lib] name = "base_format"`), so the code, + each crate's own tests and doctests included, still says `base_format::`. +* Every crate gets the `description` crates.io requires and a repository link, + and each path dependency between them gets the exact version it is published at. +* crates.io packs each crate on its own, so the CLI can't `include_str!` the + quant profiles from ../../../profiles. The profiles it embeds are copied into + the crate and the paths shortened. The public profiles and the engine header + that tests read from the workspace are copied in too, into the crates whose + tests read them (`cargo test` on a crate from crates.io); those tests look in + the crate first. +* baseRT-sys carries the engine's C headers in its own include/baseRT/, which + its build script hands to dependents' build scripts as DEP_BASERT_INCLUDE. +* Every crate carries the LICENSE and NOTICE. +""" + +from __future__ import annotations + +import re +import shutil +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +HERE = Path(__file__).resolve().parent +REPOSITORY = "https://github.com/basecompute/baseRT" +HOMEPAGE = "https://docs.basecompute.co" + +# Source crate name -> (crates.io name, description). +CLI_CRATES = { + "base-format": ("basert-format", "Reader and writer for the BaseRT .base model format"), + "base-quant": ("basert-quant", "Quantization packing routines for BaseRT .base models"), + "base-awq": ("basert-awq", "AWQ (activation-aware weight quantization) calibration for BaseRT models"), + "base-readers": ("basert-readers", "GGUF, MLX-safetensors and HF-safetensors readers for the BaseRT converter"), + "base-arch": ("basert-arch", "Per-architecture tensor-name normalization for the BaseRT converter"), + "base-sign": ("basert-sign", "ed25519 signing and verification of BaseRT .base manifests"), + "base-hub": ("basert-hub", "Model hub for BaseRT: resolve, download and catalog .base models"), + "base-convert": ("basert-cli", "The basert CLI: pull, convert and run LLMs locally with BaseRT"), +} +BINDING_CRATES = ["baseRT-sys", "baseRT"] +IGNORE = shutil.ignore_patterns("target", ".DS_Store") + + +def fail(msg: str) -> None: + sys.exit(f"stage.py: {msg}") + + +def sub_once(pattern: str, repl: str, text: str, what: str) -> str: + new, n = re.subn(pattern, repl, text, count=1, flags=re.M) + if n != 1: + fail(f"could not {what}") + return new + + +def add_package_fields(manifest: Path, fields: dict[str, str]) -> None: + """Insert `key = value` lines right after [package] name, skipping keys already set.""" + text = manifest.read_text() + pkg = text[: text.find("\n[", text.find("[package]") + 1)] + lines = "".join( + f"{k} = {v}\n" for k, v in fields.items() if not re.search(rf"^{re.escape(k)}(\.workspace)?\s*=", pkg, re.M) + ) + text = sub_once(r"^(name = .*\n)", lambda m: m.group(1) + lines, text, f"find name in {manifest}") + manifest.write_text(text) + + +def add_license(crate: Path) -> None: + for name in ("LICENSE", "NOTICE"): + shutil.copy(ROOT / name, crate / name) + + +def stage_cli(out: Path) -> str: + ws = out / "cli" + shutil.copytree(ROOT / "base-convert", ws, ignore=IGNORE) + root_toml = ws / "Cargo.toml" + text = root_toml.read_text() + version = re.search(r'^\[workspace\.package\][^\[]*?^version = "([^"]+)"', text, re.M | re.S).group(1) + + for old, (new, _) in CLI_CRATES.items(): + if old == "base-convert": + continue # the CLI itself; nothing depends on it + text = sub_once( + rf'^{re.escape(old)} = \{{ path = "(crates/{re.escape(old)})" \}}$', + rf'{old} = {{ package = "{new}", version = "={version}", path = "\1" }}', + text, + f"rewrite workspace dependency {old}", + ) + root_toml.write_text(text) + + for old, (new, description) in CLI_CRATES.items(): + crate = ws / "crates" / old + manifest = crate / "Cargo.toml" + m = sub_once(rf'^name = "{re.escape(old)}"$', f'name = "{new}"', manifest.read_text(), f"rename {old}") + if (crate / "src" / "lib.rs").exists(): + if re.search(r"^\[lib\]", m, re.M): + fail(f"{old} already has a [lib] section") + m += f'\n[lib]\nname = "{old.replace("-", "_")}"\n' + manifest.write_text(m) + add_package_fields( + manifest, + {"description": f'"{description}"', "repository": f'"{REPOSITORY}"', "homepage": f'"{HOMEPAGE}"'}, + ) + add_license(crate) + + # The CLI's crate page gets the workspace README (what `basert` is). + cli = ws / "crates" / "base-convert" + shutil.copy(ws / "README.md", cli / "README.md") + add_package_fields(cli / "Cargo.toml", {"readme": '"README.md"'}) + + # Embedded profiles: copy each into the crate, then point include_str! at the copy. + for src in (cli / "src").rglob("*.rs"): + code = src.read_text() + for rel in sorted(set(re.findall(r'include_str!\("\.\./\.\./\.\./(profiles/[^"]+)"\)', code))): + dest = cli / rel + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(ws / rel, dest) + src.write_text(code.replace('include_str!("../../../profiles/', 'include_str!("../profiles/')) + + # What tests read from the workspace: base-quant's and the CLI's read the + # public profiles, and the CLI's the engine header (its ABI number). + for crate in ("base-quant", "base-convert"): + shutil.copytree(ws / "profiles", ws / "crates" / crate / "profiles", dirs_exist_ok=True) + (cli / "include" / "baseRT").mkdir(parents=True) + shutil.copy(ROOT / "include" / "baseRT" / "baseRT.h", cli / "include" / "baseRT" / "baseRT.h") + + for src in (ws / "crates").rglob("*.rs"): + if re.search(r'include_(str|bytes)!\("\.\./\.\./\.\.', src.read_text()): + fail(f"{src.relative_to(ws)} still embeds a file from outside its crate") + return version + + +def stage_bindings(out: Path, version: str) -> None: + ws = out / "bindings" + shutil.copytree(ROOT / "bindings" / "rust", ws, ignore=IGNORE) + (ws / "Cargo.toml").write_text( + "# Generated by packaging/crates/stage.py so `cargo publish --workspace`\n" + "# publishes baseRT-sys before baseRT.\n" + '[workspace]\nresolver = "2"\nmembers = ["baseRT-sys", "baseRT"]\n' + ) + shutil.copytree(ROOT / "include" / "baseRT", ws / "baseRT-sys" / "include" / "baseRT") + for crate in BINDING_CRATES: + manifest = ws / crate / "Cargo.toml" + found = re.search(r'^version = "([^"]+)"', manifest.read_text(), re.M).group(1) + if found != version: + fail(f"{crate} is at {found} but the CLI workspace is at {version}") + shutil.copy(ws / "README.md", ws / crate / "README.md") + add_license(ws / crate) + add_package_fields( + manifest, {"repository": f'"{REPOSITORY}"', "homepage": f'"{HOMEPAGE}"', "readme": '"README.md"'} + ) + manifest = ws / "baseRT" / "Cargo.toml" + manifest.write_text( + sub_once( + r'^baseRT-sys = \{ path = "\.\./baseRT-sys" \}$', + f'baseRT-sys = {{ version = "={version}", path = "../baseRT-sys" }}', + manifest.read_text(), + "pin baseRT-sys", + ) + ) + + +def main() -> None: + if len(sys.argv) != 2: + sys.exit(__doc__) + # Only the public source tree is published. The engine's private checkout, + # which has tools/sync_public.py, makes one with packaging/public-tree.sh. + if (ROOT / "tools" / "sync_public.py").exists(): + fail("run this from the tree packaging/public-tree.sh builds, not the internal checkout") + out = Path(sys.argv[1]).resolve() + if out.exists(): + fail(f"{out} already exists") + out.mkdir(parents=True) + version = stage_cli(out) + stage_bindings(out, version) + print(f"staged {version}: {out / 'cli'} and {out / 'bindings'}") + + +if __name__ == "__main__": + main() diff --git a/packaging/npm/README.md b/packaging/npm/README.md new file mode 100644 index 0000000..86dc79e --- /dev/null +++ b/packaging/npm/README.md @@ -0,0 +1,41 @@ +# @basecompute/basert + +Fast local LLM inference on Apple Silicon (Metal) and NVIDIA GB10 / DGX Spark +(CUDA). This package installs the `basert` CLI, the prebuilt engine, and the +Node.js / TypeScript bindings. + +```sh +npm install -g @basecompute/basert # the CLI +npm install @basecompute/basert # the bindings, in a project +``` + +Requires Node 18+, and Apple Silicon with macOS 15+ or Linux/arm64 with an +NVIDIA GPU and the CUDA 13 runtime (cuBLAS). npm installs only the engine for +your platform (`@basecompute/basert-darwin-arm64` or +`@basecompute/basert-linux-arm64-cuda`), so don't install with +`--omit=optional`. + +## CLI + +```sh +basert pull Qwen/Qwen3-4B # download from Hugging Face and convert to .base +basert chat Qwen/Qwen3-4B # chat in the terminal +basert serve Qwen/Qwen3-4B --api-key "$(uuidgen)" --port 8080 # OpenAI-compatible API +``` + +## Node.js + +```typescript +import { BaseRTModel } from "@basecompute/basert"; + +const model = new BaseRTModel("model.base"); +const text = model.generateText(model.encode("Hello, world!"), 128); +model.close(); +``` + +Docs: · Source: + +## License + +The bindings and the `basert` CLI launcher are Apache-2.0. The engine packages +are under the BaseRT Engine license included in each. diff --git a/packaging/npm/basert.js b/packaging/npm/basert.js new file mode 100644 index 0000000..afcbbea --- /dev/null +++ b/packaging/npm/basert.js @@ -0,0 +1,75 @@ +#!/usr/bin/env node +// The `basert` command installed by `npm install -g @basecompute/basert`. +// +// Copied into the package as bin/basert.js by packaging/npm/build-packages.mjs. +// npm puts the prebuilt engine in whichever optional dependency matches this +// machine; this runs the `basert` launcher from that package's engine/ folder, +// where it finds the `basert-*` runtime tools and kernel files beside it. +"use strict"; + +const { spawnSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const ENGINE_PACKAGES = { + "darwin-arm64": "@basecompute/basert-darwin-arm64", + "linux-arm64": "@basecompute/basert-linux-arm64-cuda", +}; + +const platform = `${process.platform}-${process.arch}`; +const pkg = ENGINE_PACKAGES[platform]; +if (!pkg) { + console.error( + `basert: no prebuilt engine for ${platform}. BaseRT ships macOS/arm64 (Apple Silicon) and Linux/arm64 (NVIDIA CUDA).` + ); + process.exit(1); +} + +let exe; +try { + exe = path.join(path.dirname(require.resolve(`${pkg}/package.json`)), "engine", "basert"); +} catch { + // A release lists only the engine packages it publishes (see + // build-packages.mjs --engines); this machine's may not be one of them. + const published = pkg in (require("../package.json").optionalDependencies || {}); + console.error( + published + ? `basert: the engine package ${pkg} is not installed. Reinstall without --no-optional / --omit=optional.` + : `basert: this release has no npm engine package for ${platform}. Install the engine with install.sh or Homebrew; the Node.js bindings find it there.` + ); + process.exit(1); +} + +// Some package managers drop the executable bit when unpacking; restore it on +// every program in the folder. Programs are known by their Mach-O or ELF +// header, not their name, so one the bundle gains later is covered too. +function isProgram(file) { + let fd; + try { + fd = fs.openSync(file, "r"); + const head = Buffer.alloc(4); + if (fs.readSync(fd, head, 0, 4, 0) < 4) return false; + // 64-bit Mach-O, universal Mach-O, ELF. + return head.readUInt32LE(0) === 0xfeedfacf || head.readUInt32BE(0) === 0xcafebabe || head.readUInt32BE(0) === 0x7f454c46; + } catch { + return false; // a directory, or unreadable + } finally { + if (fd !== undefined) fs.closeSync(fd); + } +} +try { + fs.accessSync(exe, fs.constants.X_OK); +} catch { + const dir = path.dirname(exe); + for (const f of fs.readdirSync(dir)) { + if (isProgram(path.join(dir, f))) fs.chmodSync(path.join(dir, f), 0o755); + } +} + +const result = spawnSync(exe, process.argv.slice(2), { stdio: "inherit" }); +if (result.error) { + console.error(`basert: failed to start ${exe}: ${result.error.message}`); + process.exit(1); +} +if (result.signal) process.kill(process.pid, result.signal); +process.exit(result.status ?? 1); diff --git a/packaging/npm/build-packages.mjs b/packaging/npm/build-packages.mjs new file mode 100644 index 0000000..0971eee --- /dev/null +++ b/packaging/npm/build-packages.mjs @@ -0,0 +1,170 @@ +#!/usr/bin/env node +// Build the npm packages for one release. +// +// node packaging/npm/build-packages.mjs [--engines ] +// +// holds both release engine bundles for the version in +// bindings/node/package.json. Writes three tarballs to , ready for +// `npm publish `: +// +// basecompute-basert-darwin-arm64-.tgz engine for Apple Silicon +// basecompute-basert-linux-arm64-cuda-.tgz engine for Linux/arm64 CUDA +// basecompute-basert-.tgz bindings + `basert` command +// +// The engine is split out so each machine downloads only its own (esbuild and +// biome ship the same way): the main package lists both engine packages as +// optionalDependencies, and npm skips the one whose os/cpu don't match. +// +// --engines names the engine packages this release publishes, comma-separated +// (default: all, `basert-darwin-arm64,basert-linux-arm64-cuda`). Every engine +// tarball is still built, for testing, but the main package depends only on +// the named ones: one that never reaches the registry must not be among its +// optionalDependencies. publish.yml leaves the Linux engine out until its +// licence covers Linux. +// +// bindings/node is compiled in a staging copy and its package.json rewritten +// there, so the published package gets its own name and metadata while the +// source folder stays as it is for development. + +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(here, "..", ".."); +// Only the public source tree is packaged. The engine's private checkout, +// which has tools/sync_public.py, makes one with packaging/public-tree.sh. +if (fs.existsSync(path.join(root, "tools/sync_public.py"))) { + console.error("run this from the tree packaging/public-tree.sh builds, not the internal checkout"); + process.exit(1); +} +const argv = process.argv.slice(2); +const flag = argv.indexOf("--engines"); +const engines = flag === -1 ? null : (argv.splice(flag, 2)[1] || "").split(",").filter(Boolean); +const [bundlesDir, outDir] = argv.map((p) => path.resolve(p)); +if (argv.length !== 2 || (engines && engines.length === 0)) { + console.error("usage: build-packages.mjs [--engines ]"); + process.exit(2); +} + +const SCOPE = "@basecompute"; +const REPOSITORY = { type: "git", url: "git+https://github.com/basecompute/baseRT.git" }; +const HOMEPAGE = "https://docs.basecompute.co"; + +// One entry per release bundle. `name` must match ENGINE_PACKAGES in +// bindings/node/src/index.ts and packaging/npm/basert.js. +const PLATFORMS = [ + { + bundle: "basert-engine-macos-arm64", + name: `${SCOPE}/basert-darwin-arm64`, + description: "BaseRT engine for Apple Silicon (Metal)", + os: ["darwin"], + cpu: ["arm64"], + }, + { + bundle: "basert-engine-linux-arm64-cuda", + name: `${SCOPE}/basert-linux-arm64-cuda`, + description: "BaseRT engine for Linux/arm64 with NVIDIA CUDA (GB10 / DGX Spark)", + os: ["linux"], + cpu: ["arm64"], + libc: ["glibc"], + }, +]; + +const published = PLATFORMS.filter((p) => !engines || engines.includes(p.name.split("/")[1])); +for (const name of engines || []) { + if (!PLATFORMS.some((p) => p.name === `${SCOPE}/${name}`)) { + console.error(`--engines: no engine package ${name}`); + process.exit(2); + } +} + +const run = (cmd, args, cwd) => execFileSync(cmd, args, { cwd, stdio: ["ignore", "pipe", "inherit"] }).toString(); +const writeJson = (file, obj) => fs.writeFileSync(file, JSON.stringify(obj, null, 2) + "\n"); + +const source = JSON.parse(fs.readFileSync(path.join(root, "bindings/node/package.json"), "utf8")); +const version = source.version; +const stage = fs.mkdtempSync(path.join(os.tmpdir(), "basert-npm-")); +fs.mkdirSync(outDir, { recursive: true }); + +function pack(dir) { + const [{ filename }] = JSON.parse(run("npm", ["pack", "--json", "--pack-destination", outDir], dir)); + console.log(`packed ${filename}`); +} + +try { + // --- engine packages ------------------------------------------------------- + for (const p of PLATFORMS) { + const bundle = path.join(bundlesDir, `${p.bundle}-${version}.tar.gz`); + if (!fs.existsSync(bundle)) { + throw new Error(`missing ${bundle} (bindings/node is at ${version}; has release v${version} been published?)`); + } + const dir = path.join(stage, p.name.split("/")[1]); + run(path.join(root, "packaging/stage-engine.sh"), [bundle, path.join(dir, "engine")]); + fs.copyFileSync(path.join(dir, "engine/LICENSE"), path.join(dir, "LICENSE")); + fs.writeFileSync( + path.join(dir, "README.md"), + `# ${p.name}\n\n${p.description}. Installed automatically by ` + + `[\`${SCOPE}/basert\`](https://www.npmjs.com/package/${SCOPE}/basert); ` + + `don't depend on it directly.\n` + ); + writeJson(path.join(dir, "package.json"), { + name: p.name, + version, + description: p.description, + license: "SEE LICENSE IN LICENSE", + repository: REPOSITORY, + homepage: HOMEPAGE, + os: p.os, + cpu: p.cpu, + ...(p.libc && { libc: p.libc }), + files: ["engine/", "LICENSE", "README.md"], + preferUnplugged: true, + }); + pack(dir); + } + + // --- main package: compile the bindings in a staging copy ----------------- + const build = path.join(stage, "build"); + fs.mkdirSync(build); + for (const f of ["src", "tsconfig.json", "package.json", "package-lock.json"]) { + fs.cpSync(path.join(root, "bindings/node", f), path.join(build, f), { recursive: true }); + } + run("npm", ["ci", "--ignore-scripts", "--no-audit", "--no-fund"], build); + run("npx", ["tsc"], build); + + const main = path.join(stage, "basert"); + fs.mkdirSync(path.join(main, "bin"), { recursive: true }); + fs.cpSync(path.join(build, "dist"), path.join(main, "dist"), { + recursive: true, + filter: (f) => !f.includes("__tests__"), + }); + fs.copyFileSync(path.join(here, "basert.js"), path.join(main, "bin/basert.js")); + fs.chmodSync(path.join(main, "bin/basert.js"), 0o755); + fs.copyFileSync(path.join(here, "README.md"), path.join(main, "README.md")); + fs.copyFileSync(path.join(root, "LICENSE"), path.join(main, "LICENSE")); + fs.copyFileSync(path.join(root, "NOTICE"), path.join(main, "NOTICE")); + writeJson(path.join(main, "package.json"), { + name: `${SCOPE}/basert`, + version, + description: "Fast local LLM inference on Apple Silicon and NVIDIA GB10: the basert CLI and Node.js bindings", + license: "Apache-2.0", + repository: { ...REPOSITORY, directory: "bindings/node" }, + homepage: HOMEPAGE, + keywords: ["llm", "inference", "apple-silicon", "metal", "cuda", "openai"], + main: source.main, + types: source.types, + bin: { basert: "bin/basert.js" }, + engines: source.engines, + os: ["darwin", "linux"], + cpu: ["arm64"], + dependencies: source.dependencies, + optionalDependencies: Object.fromEntries(published.map((p) => [p.name, version])), + files: ["dist/**/*.js", "dist/**/*.d.ts", "bin/", "NOTICE"], + }); + pack(main); +} finally { + fs.rmSync(stage, { recursive: true, force: true }); +} diff --git a/packaging/python/README.md b/packaging/python/README.md new file mode 100644 index 0000000..eac03f9 --- /dev/null +++ b/packaging/python/README.md @@ -0,0 +1,37 @@ +# BaseRT + +Fast local LLM inference on Apple Silicon (Metal) and NVIDIA GB10 / DGX Spark +(CUDA). This package installs the `basert` CLI, the prebuilt engine, and the +Python bindings in one step. + +```sh +pip install basert +``` + +Requires Apple Silicon with macOS 15+, or Linux/arm64 with an NVIDIA GPU and +the CUDA 13 runtime (cuBLAS). + +## CLI + +```sh +basert pull Qwen/Qwen3-4B # download from Hugging Face and convert to .base +basert chat Qwen/Qwen3-4B # chat in the terminal +basert serve Qwen/Qwen3-4B --api-key "$(uuidgen)" --port 8080 # OpenAI-compatible API +``` + +## Python + +```python +import baseRT + +with baseRT.Model("model.base") as model: + print(model.generate_text("The capital of France is", max_tokens=64)) +``` + +Docs: · Source: + +## License + +The Python bindings and the `basert` CLI launcher are Apache-2.0. The bundled +engine binaries (`baseRT/_engine/`) are under the BaseRT Engine license +included with the package. diff --git a/packaging/python/_cli.py b/packaging/python/_cli.py new file mode 100644 index 0000000..8c267ca --- /dev/null +++ b/packaging/python/_cli.py @@ -0,0 +1,22 @@ +"""The `basert` command installed by `pip install basert`. + +Copied into the wheel as baseRT/_cli.py by packaging/python/build-wheel.sh. +It replaces this process with the `basert` launcher bundled in _engine/ rather +than running it as a child, so signals, exit codes and the terminal pass +straight through, and the launcher sees its real path and finds the `basert-*` +runtime tools and kernel files sitting beside it. +""" + +import os +import sys +from pathlib import Path + + +def main() -> None: + exe = Path(__file__).resolve().parent / "_engine" / "basert" + if not os.access(exe, os.X_OK): + sys.exit( + f"basert: the bundled launcher is missing or not executable ({exe}). " + "Reinstall with `pip install --force-reinstall basert`." + ) + os.execv(exe, [str(exe), *sys.argv[1:]]) diff --git a/packaging/python/build-wheel.sh b/packaging/python/build-wheel.sh new file mode 100755 index 0000000..8ea492d --- /dev/null +++ b/packaging/python/build-wheel.sh @@ -0,0 +1,47 @@ +#!/bin/sh +# Build the PyPI wheel for one release engine bundle. +# +# packaging/python/build-wheel.sh basert-engine-macos-arm64-0.2.6.tar.gz dist/ +# +# Produces basert--py3-none-.whl in the output folder: the +# Python bindings from bindings/python, the `basert` console command, and the +# engine under baseRT/_engine/. Needs python3 with `build` and `wheel` +# (pip install build wheel). See pyproject.toml for why this stages a copy. +set -eu + +[ $# -eq 2 ] || { echo "usage: $0 " >&2; exit 2; } +bundle=$1 +out=$2 +here=$(cd "$(dirname "$0")" && pwd) +root=$(cd "$here/../.." && pwd) +# Only the public source tree is packaged. The engine's private checkout, +# which has tools/sync_public.py, makes one with packaging/public-tree.sh. +[ ! -e "$root/tools/sync_public.py" ] || { + echo "run this from the tree packaging/public-tree.sh builds, not the internal checkout" >&2; exit 1; } + +# The bundle name carries the release version: basert-engine--.tar.gz +version=$(basename "$bundle" .tar.gz | sed 's/.*-\([0-9][^-]*\)$/\1/') +src_version=$(sed -n 's/^__version__ = "\(.*\)"$/\1/p' "$root/bindings/python/baseRT/__init__.py") +if [ "$version" != "$src_version" ]; then + echo "version mismatch: bundle is $version but bindings/python is $src_version" >&2 + echo "(bindings/python has not been bumped to this release yet)" >&2 + exit 1 +fi + +stage=$(mktemp -d) +trap 'rm -rf "$stage"' EXIT + +mkdir -p "$stage/baseRT" +cp "$root"/bindings/python/baseRT/*.py "$stage/baseRT/" +cp "$here/_cli.py" "$stage/baseRT/_cli.py" +cp "$here/pyproject.toml" "$here/README.md" "$stage/" +cp "$root/LICENSE" "$root/NOTICE" "$stage/" +"$root/packaging/stage-engine.sh" "$bundle" "$stage/baseRT/_engine" + +tag=$(python3 "$here/platform_tag.py" "$stage/baseRT/_engine") + +python3 -m build --wheel --outdir "$stage/dist" "$stage" +python3 -m wheel tags --remove --platform-tag "$tag" "$stage"/dist/*.whl >/dev/null +mkdir -p "$out" +mv "$stage"/dist/*.whl "$out/" +echo "built $(ls "$out"/*"$tag".whl)" diff --git a/packaging/python/platform_tag.py b/packaging/python/platform_tag.py new file mode 100644 index 0000000..754f248 --- /dev/null +++ b/packaging/python/platform_tag.py @@ -0,0 +1,69 @@ +"""Print the wheel platform tag a staged engine folder needs. + + python packaging/python/platform_tag.py + +The tag is read from the binaries, not hard-coded, so it follows the engine +build: on macOS the highest minimum-OS version any Mach-O file declares +(0.2.6 needs macOS 15, so macosx_15_0_arm64); on Linux the highest glibc symbol +version any ELF file needs (0.2.6 needs 2.38, so manylinux_2_38_aarch64). pip +then refuses the wheel on a machine the engine could not run on, instead of +installing it and failing at load time. +""" + +from __future__ import annotations + +import re +import struct +import sys +from pathlib import Path + +MH_MAGIC_64 = 0xFEEDFACF +LC_BUILD_VERSION = 0x32 +LC_VERSION_MIN_MACOSX = 0x24 +ELF_MAGIC = b"\x7fELF" +GLIBC_RE = re.compile(rb"GLIBC_2\.(\d+)(?:\.\d+)?\x00") + + +def macho_min_os(data: bytes) -> tuple[int, int] | None: + if len(data) < 32 or struct.unpack_from("> 16, (minos >> 8) & 0xFF + if cmd == LC_VERSION_MIN_MACOSX: + v = struct.unpack_from("> 16, (v >> 8) & 0xFF + off += size + return None + + +def main() -> None: + if len(sys.argv) != 2: + sys.exit(__doc__) + engine = Path(sys.argv[1]) + macos: list[tuple[int, int]] = [] + glibc: list[int] = [] + for f in sorted(engine.iterdir()): + if not f.is_file(): + continue + data = f.read_bytes() + if (v := macho_min_os(data)) is not None: + macos.append(v) + elif data[:4] == ELF_MAGIC: + glibc.extend(int(m) for m in GLIBC_RE.findall(data)) + + if macos and not glibc: + major, minor = max(macos) + print(f"macosx_{major}_{minor}_arm64") + elif glibc and not macos: + print(f"manylinux_2_{max(glibc)}_aarch64") + else: + sys.exit(f"{engine}: expected only Mach-O or only ELF binaries") + + +if __name__ == "__main__": + main() diff --git a/packaging/python/pyproject.toml b/packaging/python/pyproject.toml new file mode 100644 index 0000000..8a188b3 --- /dev/null +++ b/packaging/python/pyproject.toml @@ -0,0 +1,51 @@ +# PyPI packaging for `pip install basert`. +# +# Not built in place. packaging/python/build-wheel.sh copies bindings/python +# into a staging folder, puts this file, README.md and _cli.py beside it, adds the +# prebuilt engine from the GitHub release under baseRT/_engine/, and builds one +# wheel per platform. The wheel is then retagged for that platform, since the +# Python code itself is platform-independent (ctypes, no compiled extension). + +[build-system] +requires = ["setuptools>=77", "wheel>=0.42"] +build-backend = "setuptools.build_meta" + +[project] +name = "basert" +dynamic = ["version"] +description = "Fast local LLM inference on Apple Silicon and NVIDIA GB10: the basert CLI, engine, and Python bindings" +readme = "README.md" +# The bindings and CLI launcher are Apache-2.0; the bundled engine binaries in +# baseRT/_engine/ are under the BaseRT Engine license (_engine/LICENSE). +license = "Apache-2.0 AND LicenseRef-BaseRT-Engine" +license-files = ["LICENSE", "NOTICE", "baseRT/_engine/LICENSE", "baseRT/_engine/NOTICE"] +requires-python = ">=3.9" +authors = [{ name = "BaseCompute" }] +keywords = ["llm", "inference", "apple-silicon", "metal", "cuda", "openai"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: GPU", + "Intended Audience :: Developers", + "Operating System :: MacOS", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3", + "Topic :: Scientific/Engineering :: Artificial Intelligence", +] + +[project.urls] +Homepage = "https://github.com/basecompute/baseRT" +Documentation = "https://docs.basecompute.co" +Changelog = "https://github.com/basecompute/baseRT/blob/main/CHANGELOG.md" + +[project.scripts] +basert = "baseRT._cli:main" + +[tool.setuptools] +packages = ["baseRT"] +include-package-data = false + +[tool.setuptools.package-data] +baseRT = ["_engine/*"] + +[tool.setuptools.dynamic] +version = { attr = "baseRT.__version__" } diff --git a/packaging/stage-engine.sh b/packaging/stage-engine.sh new file mode 100755 index 0000000..467b449 --- /dev/null +++ b/packaging/stage-engine.sh @@ -0,0 +1,66 @@ +#!/bin/sh +# Unpack a release engine bundle into DEST as the flat folder the PyPI wheel +# and the npm engine packages ship: every runtime file side by side, exactly as +# install.sh lays out ~/.basert, minus the C headers. +# +# packaging/stage-engine.sh basert-engine-macos-arm64-0.4.1.tar.gz out/engine +# +# Keeping the release layout matters: the `basert` launcher finds the +# `basert-*` runtime tools next to itself, and those find their kernel files +# (baseRT*.metallib / baseRT_cuda_kernels.fatbin) the same way. +set -eu + +[ $# -eq 2 ] || { echo "usage: $0 " >&2; exit 2; } +bundle=$1 +dest=$2 + +mkdir -p "$dest" +tar -xzf "$bundle" -C "$dest" +rm -rf "$dest/include" + +# Wheels (zip) and npm tarballs cannot carry symlinks, so the versioned library +# chain (libbaseRT.dylib -> libbaseRT.0.dylib -> libbaseRT.0.4.1.dylib, and the +# .so equivalent) collapses to the file it leads to, under the name the +# bindings load. Every other libbaseRT file is dropped: the 0.4.1 CUDA bundle, +# built in a warm build directory, carries libbaseRT.so.0.2.0 and .so.0.4.0 +# beside the release's own. +if [ -e "$dest/libbaseRT.dylib" ]; then + name=libbaseRT.dylib +elif [ -e "$dest/libbaseRT.so" ]; then + name=libbaseRT.so +else + echo "$bundle has no libbaseRT.dylib or libbaseRT.so" >&2; exit 1 +fi +real=$name +hops=0 +while [ -L "$dest/$real" ]; do + hops=$((hops + 1)) + [ $hops -le 8 ] || { echo "$name in $bundle is a symlink loop" >&2; exit 1; } + real=$(readlink "$dest/$real") + case "$real" in */*) echo "$name in $bundle leads outside the bundle ($real)" >&2; exit 1 ;; esac +done +[ -f "$dest/$real" ] || { echo "$name in $bundle leads to $real, which is not in it" >&2; exit 1; } +mv "$dest/$real" "$dest/.engine-library" +for f in "$dest"/libbaseRT.*; do + [ -e "$f" ] || [ -L "$f" ] || continue + [ -L "$f" ] || echo "dropping $(basename "$f"): $name leads to $real" >&2 + rm -f "$f" +done +mv "$dest/.engine-library" "$dest/$name" + +# A program linked against the library records the name the library gives +# itself (install name @rpath/libbaseRT.0.dylib, SONAME libbaseRT.so.0), not +# the file it was linked through, and asks for that name when it starts. So a +# Rust or C consumer pointed at this folder with BASERT_LIB_DIR needs a file by +# that name too: a second copy, as no symlink survives the packaging. +own=$(python3 "$(dirname "$0")/check_engine_links.py" --own-name "$dest/$name") +case "$own" in + "" | "$name") ;; + libbaseRT.*) cp -p "$dest/$name" "$dest/$own" ;; + *) echo "$name in $bundle calls itself $own, not a libbaseRT name" >&2; exit 1 ;; +esac +# Nothing in the folder may need a name that was dropped, and the library's +# own name must be here. +python3 "$(dirname "$0")/check_engine_links.py" "$dest" + +[ -x "$dest/basert" ] || { echo "bundle has no executable basert launcher" >&2; exit 1; } diff --git a/packaging/update-formula.sh b/packaging/update-formula.sh new file mode 100755 index 0000000..417fbe7 --- /dev/null +++ b/packaging/update-formula.sh @@ -0,0 +1,86 @@ +#!/bin/sh +# Write the Homebrew formula (basert.rb) for a release. +# +# packaging/update-formula.sh +# +# holds the release's .sha256 files (the tarballs themselves are +# not needed). The formula is written whole from the template below rather +# than patched, so a release only ever changes the version and the two +# checksums, and the diff in the tap repo says exactly that. publish.yml opens +# that diff as a PR on the tap (basecompute/homebrew-tap, Formula/basert.rb). +set -eu + +[ $# -eq 3 ] || { echo "usage: $0 " >&2; exit 2; } +version=$1 +dir=$2 +out=$3 + +sha() { + f="$dir/$1-$version.tar.gz.sha256" + [ -f "$f" ] || { echo "missing $f" >&2; exit 1; } + s=$(cut -d' ' -f1 "$f") + printf '%s' "$s" | grep -Eq '^[0-9a-f]{64}$' || { echo "bad checksum in $f" >&2; exit 1; } + printf '%s' "$s" +} +mac_sha=$(sha basert-engine-macos-arm64) +linux_sha=$(sha basert-engine-linux-arm64-cuda) +base="https://github.com/basecompute/baseRT/releases/download/v$version" + +mkdir -p "$out" +cat > "$out/basert.rb" < $version" diff --git a/tests/scripts/test_packaging.py b/tests/scripts/test_packaging.py new file mode 100644 index 0000000..0fd3813 --- /dev/null +++ b/tests/scripts/test_packaging.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +"""Unit tests for packaging/stage-engine.sh and packaging/check_engine_links.py. + +The bundles here are synthetic: a library is a few bytes of text, and the +Mach-O and ELF programs carry only the load commands the link check reads. +""" + +from __future__ import annotations + +import io +import json +import os +import shutil +import struct +import subprocess +import sys +import tarfile +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +STAGE = ROOT / "packaging" / "stage-engine.sh" +BASERT_JS = ROOT / "packaging" / "npm" / "basert.js" +sys.path.insert(0, str(ROOT / "packaging")) +from check_engine_links import missing_links, missing_own_names # noqa: E402 + + +def macho(deps: list[str], own_id: str | None = None) -> bytes: + """A 64-bit Mach-O with an LC_LOAD_DYLIB per dep (and LC_ID_DYLIB when given).""" + cmds = [(0xD, own_id)] if own_id else [] + cmds += [(0xC, d) for d in deps] + body = b"" + for cmd, name in cmds: + s = name.encode() + b"\0" + size = (24 + len(s) + 7) // 8 * 8 + body += struct.pack("<6I", cmd, size, 24, 2, 0x10000, 0x10000) + s.ljust(size - 24, b"\0") + header = struct.pack(" bytes: + """A 64-bit little-endian ELF with one PT_LOAD and a PT_DYNAMIC naming `needed` (and DT_SONAME when given).""" + strtab, offsets = b"\0", [] + for n in needed + ([soname] if soname else []): + offsets.append(len(strtab)) + strtab += n.encode() + b"\0" + str_off = 64 + 2 * 56 + dyn_off = (str_off + len(strtab) + 7) // 8 * 8 + dyn = b"".join(struct.pack(" None: + """Write a flat release-style tarball: `files` (in order) and symlinks.""" + with tarfile.open(path, "w:gz") as tar: + for name, data in files.items(): + info = tarfile.TarInfo(f"./{name}") + info.size = len(data) + info.mode = 0o755 + tar.addfile(info, io.BytesIO(data)) + for name, target in links.items(): + info = tarfile.TarInfo(f"./{name}") + info.type = tarfile.SYMTYPE + info.linkname = target + tar.addfile(info) + + +class StageEngineTests(unittest.TestCase): + def setUp(self) -> None: + self.tmp = Path(tempfile.mkdtemp()) + + def stage(self, files: dict[str, bytes], links: dict[str, str]) -> tuple[subprocess.CompletedProcess, Path]: + tarball = self.tmp / "bundle.tar.gz" + bundle(tarball, files, links) + dest = self.tmp / "engine" + run = subprocess.run(["sh", str(STAGE), str(tarball), str(dest)], capture_output=True, text=True) + return run, dest + + def libs(self, dest: Path) -> dict[str, bytes]: + return {p.name: p.read_bytes() for p in dest.iterdir() if p.name.startswith("libbaseRT.")} + + def test_the_library_the_unversioned_link_names_ships_alone(self) -> None: + # The 0.4.1 CUDA bundle: two older libraries from a warm build + # directory sit beside the release's, written before it. + run, dest = self.stage( + { + "libbaseRT.so.0.2.0": b"stale 0.2.0", + "libbaseRT.so.0.4.0": b"stale 0.4.0", + "libbaseRT.so.0.4.1": b"release", + "basert": elf([]), + }, + {"libbaseRT.so.0": "libbaseRT.so.0.4.1", "libbaseRT.so": "libbaseRT.so.0"}, + ) + self.assertEqual(run.returncode, 0, run.stderr) + self.assertEqual(self.libs(dest), {"libbaseRT.so": b"release"}) + + def test_the_macos_chain_collapses_to_libbaseRT_dylib(self) -> None: + run, dest = self.stage( + {"libbaseRT.0.4.1.dylib": b"release", "basert": macho([])}, + {"libbaseRT.0.dylib": "libbaseRT.0.4.1.dylib", "libbaseRT.dylib": "libbaseRT.0.dylib"}, + ) + self.assertEqual(run.returncode, 0, run.stderr) + self.assertEqual(self.libs(dest), {"libbaseRT.dylib": b"release"}) + self.assertFalse((dest / "include").exists()) + + def test_the_library_also_ships_under_the_name_it_records(self) -> None: + # A program linked against the library asks for its install name + # (SONAME on Linux) at startup, so that name is a second real file. + lib = macho([], own_id="@rpath/libbaseRT.0.dylib") + run, dest = self.stage( + {"libbaseRT.0.4.1.dylib": lib, "basert": macho([])}, + {"libbaseRT.0.dylib": "libbaseRT.0.4.1.dylib", "libbaseRT.dylib": "libbaseRT.0.dylib"}, + ) + self.assertEqual(run.returncode, 0, run.stderr) + self.assertEqual(self.libs(dest), {"libbaseRT.dylib": lib, "libbaseRT.0.dylib": lib}) + self.assertTrue(all(not p.is_symlink() for p in dest.iterdir())) + + def test_the_linux_library_also_ships_under_its_soname(self) -> None: + so = elf(["libcuda.so.1"], soname="libbaseRT.so.0") + run, dest = self.stage( + {"libbaseRT.so.0.2.0": b"stale 0.2.0", "libbaseRT.so.0.4.1": so, "basert": elf([])}, + {"libbaseRT.so.0": "libbaseRT.so.0.4.1", "libbaseRT.so": "libbaseRT.so.0"}, + ) + self.assertEqual(run.returncode, 0, run.stderr) + self.assertEqual(self.libs(dest), {"libbaseRT.so": so, "libbaseRT.so.0": so}) + + def test_a_program_needing_a_dropped_name_is_refused(self) -> None: + # The fully versioned name is dropped; a program that asks for it + # would not start from a wheel. + run, _ = self.stage( + { + "libbaseRT.0.4.1.dylib": macho([], own_id="@rpath/libbaseRT.0.dylib"), + "basert": macho([]), + "basert-tool": macho(["@rpath/libbaseRT.0.4.1.dylib", "/usr/lib/libSystem.B.dylib"]), + }, + {"libbaseRT.0.dylib": "libbaseRT.0.4.1.dylib", "libbaseRT.dylib": "libbaseRT.0.dylib"}, + ) + self.assertNotEqual(run.returncode, 0) + self.assertIn("basert-tool needs libbaseRT.0.4.1.dylib", run.stderr) + + def test_a_library_that_calls_itself_something_else_is_refused(self) -> None: + run, _ = self.stage( + {"libbaseRT.0.4.1.dylib": macho([], own_id="@rpath/libother.dylib"), "basert": macho([])}, + {"libbaseRT.dylib": "libbaseRT.0.4.1.dylib"}, + ) + self.assertNotEqual(run.returncode, 0) + self.assertIn("calls itself libother.dylib", run.stderr) + + def test_a_bundle_without_the_unversioned_name_is_refused(self) -> None: + run, _ = self.stage({"libbaseRT.so.0.4.1": b"release", "basert": elf([])}, {}) + self.assertNotEqual(run.returncode, 0) + self.assertIn("libbaseRT.dylib or libbaseRT.so", run.stderr) + + +class EngineLinkTests(unittest.TestCase): + def setUp(self) -> None: + self.dir = Path(tempfile.mkdtemp()) + + def write(self, name: str, data: bytes) -> None: + (self.dir / name).write_bytes(data) + + def test_macho_dependency_on_a_present_name_passes(self) -> None: + self.write("libbaseRT.dylib", macho([], own_id="@rpath/libbaseRT.0.dylib")) + self.write("basert-chat", macho(["@rpath/libbaseRT.dylib", "/usr/lib/libc++.1.dylib"])) + self.assertEqual(missing_links(self.dir), []) + + def test_macho_dependency_on_a_missing_name_fails(self) -> None: + self.write("libbaseRT.dylib", macho([], own_id="@rpath/libbaseRT.0.dylib")) + self.write("basert-tool", macho(["@rpath/libbaseRT.0.dylib"])) + self.assertEqual(missing_links(self.dir), [("basert-tool", "libbaseRT.0.dylib")]) + + def test_a_library_id_is_not_a_dependency(self) -> None: + self.write("libbaseRT.dylib", macho([], own_id="@rpath/libbaseRT.0.dylib")) + self.assertEqual(missing_links(self.dir), []) + + def test_elf_dependency_on_a_missing_soname_fails(self) -> None: + self.write("libbaseRT.so", elf(["libcuda.so.1", "libc.so.6"])) + self.write("basert-tool", elf(["libbaseRT.so.0", "libcublas.so.13"])) + self.assertEqual(missing_links(self.dir), [("basert-tool", "libbaseRT.so.0")]) + + def test_a_library_whose_own_name_is_missing_is_reported(self) -> None: + # Linked through libbaseRT.dylib, a consumer records libbaseRT.0.dylib. + self.write("libbaseRT.dylib", macho([], own_id="@rpath/libbaseRT.0.dylib")) + self.write("libbaseRT.so", elf(["libc.so.6"], soname="libbaseRT.so.0")) + self.assertEqual(missing_own_names(self.dir), [("libbaseRT.dylib", "libbaseRT.0.dylib"), ("libbaseRT.so", "libbaseRT.so.0")]) + run = subprocess.run([sys.executable, str(ROOT / "packaging" / "check_engine_links.py"), str(self.dir)], capture_output=True, text=True) + self.assertEqual(run.returncode, 1) + self.assertIn("libbaseRT.dylib calls itself libbaseRT.0.dylib", run.stderr) + self.write("libbaseRT.0.dylib", macho([], own_id="@rpath/libbaseRT.0.dylib")) + self.write("libbaseRT.so.0", elf(["libc.so.6"], soname="libbaseRT.so.0")) + self.assertEqual(missing_own_names(self.dir), []) + + def test_own_name_prints_what_a_library_records(self) -> None: + self.write("libbaseRT.dylib", macho([], own_id="@rpath/libbaseRT.0.dylib")) + self.write("basert", macho(["@rpath/libbaseRT.dylib"])) + own = lambda f: subprocess.run( # noqa: E731 + [sys.executable, str(ROOT / "packaging" / "check_engine_links.py"), "--own-name", str(self.dir / f)], + capture_output=True, text=True, check=True).stdout + self.assertEqual(own("libbaseRT.dylib"), "libbaseRT.0.dylib\n") + self.assertEqual(own("basert"), "") + + def test_elf_system_libraries_are_not_ours_to_ship(self) -> None: + self.write("basert-chat", elf(["libcuda.so.1", "libcublas.so.13", "libstdc++.so.6"])) + self.write("LICENSE", b"text") + self.assertEqual(missing_links(self.dir), []) + + +class NpmLauncherTests(unittest.TestCase): + """packaging/npm/basert.js, run by node over a node_modules laid out as npm would.""" + + ENGINES = {"darwin-arm64": "basert-darwin-arm64", "linux-arm64": "basert-linux-arm64-cuda"} + + def setUp(self) -> None: + node = shutil.which("node") + engine = self.ENGINES.get(f"{sys.platform}-{os.uname().machine.replace('aarch64', 'arm64')}") + if not node or not engine: + self.skipTest("needs node on macOS/arm64 or Linux/arm64") + self.node = node + self.tmp = Path(tempfile.mkdtemp()) + self.modules = self.tmp / "node_modules" / "@basecompute" + self.engine_pkg = engine + + def install(self, listed: bool, with_engine: bool = True) -> Path: + main = self.modules / "basert" + (main / "bin").mkdir(parents=True) + shutil.copy(BASERT_JS, main / "bin" / "basert.js") + deps = {f"@basecompute/{self.engine_pkg}": "0.0.0"} if listed else {} + (main / "package.json").write_text(json.dumps({"name": "@basecompute/basert", "optionalDependencies": deps})) + engine = self.modules / self.engine_pkg / "engine" + if with_engine: + engine.mkdir(parents=True) + (engine.parent / "package.json").write_text(json.dumps({"name": f"@basecompute/{self.engine_pkg}"})) + # Unpacked without executable bits, as some package managers do. + # A program is known by its header: one named unlike the + # basert-* tools gets its bit back too. + for name in ("basert", "basert-chat", "helper"): + shutil.copy("/usr/bin/true", engine / name) + os.chmod(engine / name, 0o644) + (engine / "LICENSE").write_text("text") + os.chmod(engine / "LICENSE", 0o644) + return engine + + def launch(self) -> subprocess.CompletedProcess: + return subprocess.run( + [self.node, str(self.modules / "basert" / "bin" / "basert.js")], capture_output=True, text=True + ) + + def test_every_program_gets_its_executable_bit_back(self) -> None: + engine = self.install(listed=True) + run = self.launch() + self.assertEqual(run.returncode, 0, run.stderr) + for name in ("basert", "basert-chat", "helper"): + self.assertTrue(os.access(engine / name, os.X_OK), name) + self.assertFalse(os.access(engine / "LICENSE", os.X_OK)) + + def test_a_listed_engine_package_missing_says_to_reinstall(self) -> None: + self.install(listed=True, with_engine=False) + run = self.launch() + self.assertEqual(run.returncode, 1) + self.assertIn("--omit=optional", run.stderr) + + def test_an_engine_this_release_did_not_publish_points_at_install_sh(self) -> None: + self.install(listed=False, with_engine=False) + run = self.launch() + self.assertEqual(run.returncode, 1) + self.assertIn("this release has no npm engine package", run.stderr) + + +if __name__ == "__main__": + unittest.main()