feat(release): distribute rocm-cli as a Python wheel - #184
Conversation
Add a second distribution channel so `pip install rocm-cli` installs the prebuilt `rocm` and `rocmd` binaries, alongside the existing install.sh and install.ps1 bundles. scripts/build_wheel.py packages binaries that release CI has already built and signed, so the wheel payload stays byte-identical to the copy inside the signed archive for the same tag. The binaries ship in the wheel's `.data/scripts` directory as real executables rather than console-script shims, because `std::env::current_exe()` has to resolve to the real binary: the CLI re-execs itself to launch managed engine services and locates `rocmd` as a sibling file. Wheels land in `dist/wheels/`, a subdirectory, so the exact-asset gate in release_readiness.py keeps guarding the GitHub release asset set unchanged. Only `manylinux_2_17_x86_64` and `win_amd64` wheels are produced and no source distribution is published, so pip refuses to install on an unsupported platform instead of attempting a build. The released Linux binaries already satisfy manylinux_2_17: they link only allowlisted shared objects and need at most GLIBC_2.16. Git tags are mapped onto PEP 440 exhaustively-or-error and cross-checked against the workspace version, and both smoke tests assert that the installed files are native executables reporting the expected version. `rocm uninstall` previously deleted every rocm-named file beside the running executable. Inside a pip-created environment that deletes files pip owns and leaves the wheel RECORD dangling while pip still reports the package installed. It now detects a Python-managed layout and skips binary removal, pointing at the matching package manager instead. Probe failures resolve conservatively: a tree that exists but cannot be read is treated as managed rather than deleted. Publication is gated on the repository variable ROCM_CLI_PUBLISH_PYPI and stays dormant until the PyPI project and its Trusted Publisher exist. Until then CI builds, smoke-tests, and retains the wheels as artifacts without uploading. Signed-off-by: Michael Roy <michael.roy@amd.com>
volen-silo
left a comment
There was a problem hiding this comment.
Reviewed at 3f27beb. The wheel packaging itself holds up well — spec conformance, the .data/scripts/ rationale, the dormant publish gate, OIDC scoping, action pinning, and the dist/wheels/ subdirectory trick all check out, and the -Recurse → dist\* glob change is a genuine catch. Two blocking items below, both in the supporting fix and its coverage rather than in the packaging.
Blocking
1. is_python_managed_layout misses pip install --user on Windows — the delete path stays live
apps/rocm/src/main.rs:15133 (python_site_packages_dirs), reached from build_uninstall_plan.
The probe only considers <env_root>/Lib/site-packages and <env_root>/lib/python*/site-packages. CPython's nt_user install scheme is not shaped like that: scripts land in %APPDATA%\Python\Python3XX\Scripts and packages in %APPDATA%\Python\Python3XX\site-packages — no Lib\ level, unlike the nt scheme used for venvs and system installs. So env_root\Lib\site-packages does not exist, env_root\lib does not exist, the function returns Ok(vec![]) rather than Err(ProbeFailed), and the plan falls through to the delete branch.
That is the exact corruption this PR exists to prevent, on a supported platform. The running-executable skip spares rocm.exe, but rocmd.exe gets deleted, leaving pip's RECORD dangling while the package still reports as installed.
Smallest fix: add env_root.join("site-packages") to the candidate list. The RECORD row (..\Scripts\rocm.exe) already matches record_row_targets_script, so nothing else needs to change. Worth a unit test too — the existing coverage has a Scripts parent only in combination with a real pyvenv.cfg (is_python_managed_layout_detects_windows_scripts_directory) and the RECORD-without-pyvenv.cfg case only in a Unix-shaped tree, so this corner is precisely the untested one.
Two related gaps, lower severity, that I would at least see acknowledged in the doc comment's limitations paragraph rather than fixed here:
uv tool install/pipx installon Windows. Both symlink on Unix and copy on Windows into%USERPROFILE%\.local\bin. On Linuxcurrent_exe()resolves/proc/self/exeback into the tool venv, so detection works; on WindowsGetModuleFileNameWreturns the copy's own path, with nopyvenv.cfgand no site-packages above it. The user-facing message currently advertisesuv tool uninstallas the remedy for a case that is not detected there.- Debian/Ubuntu
dist-packages— the join at line 15157 hardcodessite-packages. Only reachable behind--break-system-packages, so minor.
The doc comment at :15054 documents one accepted limitation, and it is a false positive (the safe direction). These are false negatives, which is the direction the comment itself calls unacceptable — so they should not be silent.
2. scripts/build_wheel.py --self-test is not wired into CI
.github/workflows/ci.yml:214-228 (Linux) and :348-360 (Windows).
Every other release/acceptance script in scripts/ has its --self-test invoked in both CI lanes — release_readiness.py, wsl_preflight.py, the three *_therock_gpu_test.py harnesses, setup-wsl-portable-build-deps.sh. This adds 631 lines of new release-path code with a substantial self-test (tag mapping, workspace-version cross-check, both platform tags, member set, RECORD hashes, byte-identical rebuild, mode bits) and it runs only locally. build_wheel.py currently appears nowhere outside release.yml, which means its first CI execution would be during a real tag push, ahead of "Create release" — a regression there fails an otherwise-working release instead of failing at PR time. Two lines.
Non-blocking
.github/workflows/release.yml:415-431— the "manual dispatch defaults to TestPyPI" property relies on GitHub backfillinggithub.event.inputs.pypi_repositoryfrom the YAMLdefault:. It does, so this is not a live bug; butTARGET="${REQUESTED:-pypi}"conflates "tag push, no inputs" with "dispatch, field omitted", and the fallback for the latter is the irreversible index. Keying offgithub.event_nameexplicitly would make the guarantee provable from the workflow text alone. Cheap, and this is the one place worth being paranoid.scripts/build_wheel.py:222— theLicense-File:metadata lines are hardcoded independently ofLICENSE_FILES(:44), which drives what actually gets packaged. They agree today; they will diverge silently the first time a notices file is added, andself_test_wheeldoes not cross-check them. Derive them from the constant.- No integration coverage for the uninstall guard. The release smoke test already builds a real venv and pip-installs the wheel; adding
rocm uninstall --dry-runthere and asserting it refuses would give the headline fix genuine end-to-end coverage for one line of YAML. Currently the only coverage is unit tests over synthetic temp dirs — which is where finding 1 slipped through. - New user-visible behavior is undocumented. Neither the new Python-package section nor the existing
rocm uninstallblock inREADME.mdmentions that the CLI now declines to remove package-manager-owned binaries. - While you're here: the "More docs" index in
README.mdstill doesn't linkdocs/release-trust.md, which this PR roughly doubles. scripts/build_wheel.py:195is the third byte-for-byte copy of the chunked SHA-256 helper (release_readiness.py,build_single_exe_release.pyhave the others). In-convention, just noting the count.- Gate allowlist (
release.yml:405) accepts only1/truecase-sensitively whererelease_readiness.py'struthy()accepts{1,true,yes,on}case-insensitively. Deliberate and it cannot fail open — inconsistency only.
One tradeoff worth stating accurately
The design notes imply no standard tool could package prebuilt binaries without rebuilding. That is correct for maturin — every subcommand invokes cargo build, there is no package-a-prebuilt mode. It is not correct for hatchling: I built and pip-installed a [tool.hatch.build.targets.wheel.shared-scripts] wheel while reviewing this, and it preserved 0o755 and installed a real executable, no shim. That would replace roughly 330 of the 631 lines, leaving the tag mapping and Cargo cross-check as the domain logic no tool provides.
Hand-rolling is still defensible here — the repo has zero non-stdlib Python dependencies and build_single_exe_release.py already hand-rolls its archives with tarfile/zipfile, so this follows the house pattern. No change requested. The PR just shouldn't claim the alternative was impossible.
Also flagging that the always-on wheel build adds a new way for a real release to fail, ahead of "Create release". docs/release-trust.md says this outright rather than claiming nothing changed, which is the right call — just make sure that is a conscious acceptance and not a footnote.
Things worth reinforcing
ProbeFailedas a distinct type, with unreadable-but-exists separated from missing, makes the fail-closed property structural instead of conventional. The RECORD parser handles RFC 4180 quoting, doubled quotes, embedded commas, and CRLF correctly. Finding 1 is about which directories get probed, not this machinery.- The smoke tests are real:
pip install --no-indexinto an actual venv, execute the installed binaries, check magic bytes, match the reported version against the wheel filename,$LASTEXITCODEchecked after every native call. No|| true, no glob that passes on zero matches. - The
.data/scriptsload-bearing claim holds end to end — plain venv,--target, and--prefixinstalls all placerocmandrocmdas siblings with the exec bit intact, andpip uninstallleaves no dangling RECORD.
One claim I could not verify live and am flagging rather than endorsing: docs/release-trust.md:137-141 on pip not verifying PEP 740 attestations at install time. It matches my understanding and the doc states it conservatively, but I did not confirm it against a current pip.
Status: not for immediate merge
Opening this for review now. We will merge once we feel the quality bar is there, pending any other changes that come out of review. Note that the publish path is dormant by design (see the gate below), so merging this does not publish anything.
What this adds
A second distribution channel:
pip install rocm-cliinstalls the prebuiltrocmandrocmdbinaries, alongside the existinginstall.sh/install.ps1bundles.scripts/build_wheel.py(new) packages binaries the release job has already built and signed. Nothing is rebuilt, so the wheel payload stays byte-identical to the copy inside the signed archive for the same tag..github/workflows/release.ymlbuilds and smoke-tests a wheel in each existing build job and adds apublish-pypijob using PyPI Trusted Publishing.apps/rocm/src/main.rsstopsrocm uninstallfrom deleting binaries a Python package manager owns.Design decisions worth a reviewer's attention
Binaries ship in the wheel's
.data/scripts/directory, not as console-script shims. This is load-bearing, not a style choice.std::env::current_exe()has to resolve to the real binary: the CLI re-execs itself to launch managed engine services (managed_service_launcher_path) and locatesrocmdas a sibling file (sibling_binary_path). A Python shim would makecurrent_exe()the interpreter and break both. Both smoke tests assert the installed files are native executables by magic bytes (7f454c46/MZ), so a future refactor that introduced a shim fails CI rather than shipping.Wheels are written to
dist/wheels/, a subdirectory.release_readiness.py::validate_exact_dist_assetsiteratesdistfor files only, so the strict release-asset gate is untouched andrelease_readiness.pyneeded no changes. The Windows upload glob was also changed fromGet-ChildItem dist -Include … -RecursetoGet-ChildItem dist\* -File -Include …; with-Recurseit matcheddist\wheels\*.whl.sha256and would have pushed wheel sidecars into the GitHub release asset set.Wheels only, no source distribution, and only
manylinux_2_17_x86_64andwin_amd64. On any other platform pip fails with "no matching distribution" instead of attempting a Rust build, which matches the supported-platform rule in AGENTS.md §6. The existing manylinux2014 container build already satisfiesmanylinux_2_17: the released Linux binaries link only allowlisted shared objects and need at mostGLIBC_2.16.Tag to PEP 440 mapping is exhaustive-or-error and cross-checked against
[workspace.package] version, so a tag can never publish a wheel whose binaries report a different version.v0.1.0-experimental.1maps to0.1.0a1. Because PyPI never allows a version to be re-uploaded,-alpha.Nand-experimental.Ncollapse onto the sameaNform and only one spelling may be used per serial; this is documented indocs/release-trust.md.Publication is dormant.
publish-pypiruns only when the repository variableROCM_CLI_PUBLISH_PYPIis1ortrue(an allowlist, soFALSE/no/offstay dormant), mirroring the existingROCM_CLI_REQUIRE_PRODUCTION_TRUSTpattern. Until then CI builds, smoke-tests, and retains the wheels as workflow artifacts and uploads nothing. The GitHub release asset set is unchanged either way; the pipeline does gain steps that can fail, which the docs say explicitly rather than claiming nothing changed.The uninstall fix is a real bug, reproduced first.
rocm uninstallglobbed the running executable's directory and deleted every rocm-named file. Inside a pip environment:That deletes files pip owns and leaves the wheel
RECORDdangling whilepip liststill reports the package installed. A new tier inbuild_uninstall_plandetects a Python-managed layout (pyvenv.cfg, or a*.dist-info/RECORDthat claims the script) and skips binary removal, naming the pip / pipx /uv toolcommand instead. Probe failures resolve conservatively: a tree that exists but cannot be read is treated as managed rather than deleted, because a false positive only leaves files in place while a false negative corrupts another package manager's state.Trust model, stated plainly
PyPI cannot carry the detached
.sigsidecar the installers verify, so this channel has a different trust root: Trusted Publishing (OIDC, no long-lived upload token) plus PEP 740 attestations.docs/release-trust.mdis explicit that pip does not verify attestations at install time and offers no flag to require them, so for a plainpip installthey are an auditable provenance record rather than an install gate. It also states that the cross-job checksum catches transit corruption but cannot detect a compromised builder that wrote a consistent wheel and sidecar. The GitHub release assets remain the signature-bearing artifacts.Verification performed
Local, all passing:
cargo clippy --workspace --all-targets -- -D warnings,cargo test --workspace --all-targetscargo test -p rocm --bin rocm is_python_managed(6 tests), plus mutation probes: stubbing the detector tofalsefails 5 of 6, and restoring the oldRECORDparser fails the quoted-row testpython scripts/build_wheel.py --self-test,release_readiness.py --self-test,build_single_exe_release.py self-test,scripts/smoke_local.py,cargo xtask verify-pinned-keys, both WSL script self-tests,ruffv0.1.0-experimental.1Linux binaries, installed it into a fresh venv, and confirmedrocm --version,rocmd --version,rocm examine, and mode0755. Also confirmedpipx installanduv tool installplace both executablesrelease.ymlparses; every non-pwshrunblock passesbash -n; all six pwsh blocks parse under a real Windows PowerShell parser; the new PE magic check was executed there and correctly rejected a#!pythonshim; all tenuses:refs are 40-hex SHAs with a# vX.Y.ZcommentNot runnable here, so stated rather than claimed:
Owner prerequisites before the gate can be opened
AMDorganization on PyPI.rocm-cliis unregistered on both PyPI and TestPyPI;rocmitself is squatted by an unrelated placeholder, which is why the distribution name isrocm-cli.ROCm/rocm-cli, workflowrelease.yml, environmentpypi. The pending-publisher flow creates the project on first OIDC publish, so the name is claimed without ever minting an API token.workflow_dispatch(the dispatch input defaults totestpypiprecisely so the irreversible action is always opt-in).ROCM_CLI_PUBLISH_PYPI.Review notes
This branch already went through six independent adversarial review passes plus a cross-review round; 19 findings were applied and 1 was rejected on spec grounds (a claim that
License-Filemust belicenses/LICENSE.TXT; the metadata spec defines that path as relative to the project root, and the wheel then stores it at.dist-info/licenses/<path>, so the bare filename is correct). Fresh eyes are still very welcome, particularly on the workflow semantics that can only be proven by running them.tests/e2e-cucumber/expectations.tomlfor the fixed ticket ID and removed/narrowed any now-stale xfail rows. — no xfail rows relate to this change; the one existing e2e assertion on uninstall output (skipping running executable on Windows,lifecycle_steps.rs) still holds, because an installer-managed Windows binary is not detected as Python-managed and still reaches that branch.