diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 951f830..595dabc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -73,6 +73,17 @@ jobs: print(f"OK: {whl} ships only entry.js under ui-tui/, no node_modules") PY + # 4b) Export the locked dependency set as a constraints file. The install + # scripts pass this to `uv tool install -c` so a one-click install gets + # the exact versions we lock and test, instead of re-resolving to the + # newest versions allowed by pyproject (which drifts, and can pull a + # source-only build with no wheel on the user's platform). --locked + # fails the release if uv.lock is stale vs pyproject, so we never ship + # constraints that conflict with the wheel's own metadata; --all-extras + # covers the channel SDKs behind raven[channels]. + - name: Export locked constraints + run: uv export --locked --all-extras --no-hashes --no-emit-project -o dist/raven-constraints.txt + - name: Upload artifacts uses: actions/upload-artifact@v4 with: @@ -121,7 +132,7 @@ jobs: id="" fi if [ -z "$id" ]; then - gh release create "$tag" dist/*.whl dist/*.tar.gz \ + gh release create "$tag" dist/*.whl dist/*.tar.gz dist/raven-constraints.txt \ --title "Raven $ver ($today)" --notes-file "$RUNNER_TEMP/notes.md" --draft $pre else echo "Release $tag already published (id=$id); leaving as-is." diff --git a/RELEASING.md b/RELEASING.md index adc0cc0..87d03e6 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -38,7 +38,8 @@ Highlights before publishing. Structure: - Version: `X.Y.Z` - Tag: `vX.Y.Z` - Stability: # fill by hand per release type -- Assets: wheel and source distribution attached to this release +- Assets: wheel, source distribution, and locked constraints + (`raven-constraints.txt`) attached to this release ## Notes - pre-1.0 evolution caveat @@ -49,10 +50,12 @@ Highlights before publishing. Structure: ## Flow -1. Bump `version` in `pyproject.toml`; open a PR; merge to `main`. +1. Bump `version` in `pyproject.toml`, run `uv lock`, then open a PR; merge to `main`. 2. `git tag vX.Y.Z && git push origin vX.Y.Z`. -3. CI (`release.yml`) builds the wheel + sdist and creates a **draft** GitHub - Release with both attached, titled and prefilled from the template. +3. CI (`release.yml`) builds the wheel + sdist, exports the locked constraints + from `uv.lock` (`uv export --locked`, which fails the release if the lock is + stale vs pyproject), and creates a **draft** GitHub Release with all three + attached, titled and prefilled from the template. 4. Fill the summary + Highlights in the draft, then click **Publish**. Publishing makes it `/releases/latest`, which `install.sh` serves. @@ -69,3 +72,9 @@ Highlights before publishing. Structure: draft: publishing is a deliberate human step. - PyPI publishing is not wired up; the supported install path is the GitHub Release wheel asset resolved by `install.sh`. +- `install.sh` / `install.ps1` pass `raven-constraints.txt` to + `uv tool install -c`, so a one-click install gets the exact locked versions we + test rather than re-resolving to the newest allowed by `pyproject.toml`. A + release older than this asset installs without pinning (the scripts degrade + gracefully). To lift a pin later, update `uv.lock` and cut a new release; the + install scripts need no change. diff --git a/install.ps1 b/install.ps1 index acd67b7..b54b5de 100644 --- a/install.ps1 +++ b/install.ps1 @@ -202,6 +202,27 @@ function Resolve-RavenWheel { return $asset.browser_download_url } +function Resolve-RavenConstraints([string]$WheelUrl) { + # Derive the locked-constraints URL from the wheel URL (same release dir) so + # the constraints always match the wheel being installed -- including when + # RAVEN_WHEEL_URL pins an older wheel. Returns a local temp-file path, or + # $null when the asset is absent (release predates it) or the download fails, + # so the installer degrades to an unconstrained install rather than failing. + $url = $env:RAVEN_CONSTRAINTS_URL + if (-not $url) { + if ($WheelUrl -notmatch "/[^/]+\.whl$") { return $null } + $url = $WheelUrl -replace "/[^/]+\.whl$", "/raven-constraints.txt" + } + $dest = Join-Path ([IO.Path]::GetTempPath()) ("raven-constraints-" + [guid]::NewGuid().ToString("N") + ".txt") + try { + Invoke-WebRequest $url -OutFile $dest + } catch { + Write-Warn "Could not download locked constraints; installing without version pinning." + return $null + } + return $dest +} + function Install-Raven([string]$UvPath, [string]$NodePath) { $scriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path } $pyproject = Join-Path $scriptDir "pyproject.toml" @@ -225,26 +246,36 @@ function Install-Raven([string]$UvPath, [string]$NodePath) { Write-Warn "Found node but not npm; skipping TUI bundle build" } } + # Pin to the locked dependency set so an install matches what we test. + $constraints = Join-Path ([IO.Path]::GetTempPath()) ("raven-constraints-" + [guid]::NewGuid().ToString("N") + ".txt") + & $UvPath export --directory "$scriptDir" --frozen --all-extras --no-hashes --no-emit-project -o "$constraints" # Install all channel adapters by default; fall back to base raven if # the umbrella extra fails to build on this platform, so one broken # channel SDK cannot block the whole install. try { - & $UvPath tool install --force -e "$scriptDir[channels]" + & $UvPath tool install --force -c "$constraints" -e "$scriptDir[channels]" if ($LASTEXITCODE -ne 0) { throw "channel extras install failed" } } catch { Write-Warn "Channel dependencies failed to install; installed base raven only. Some channels stay unavailable (see: raven channels list)." - & $UvPath tool install --force -e "$scriptDir" + & $UvPath tool install --force -c "$constraints" -e "$scriptDir" if ($LASTEXITCODE -ne 0) { Fail "Raven install failed." } } } else { $wheelUrl = Resolve-RavenWheel + $constraints = Resolve-RavenConstraints $wheelUrl + if ($constraints) { + $cArgs = @("-c", $constraints) + } else { + Write-Warn "Release has no locked-constraints asset; installing without version pinning." + $cArgs = @() + } Write-Info " installing $wheelUrl" try { - & $UvPath tool install --force "raven[channels] @ $wheelUrl" + & $UvPath tool install --force @cArgs "raven[channels] @ $wheelUrl" if ($LASTEXITCODE -ne 0) { throw "channel extras install failed" } } catch { Write-Warn "Channel dependencies failed to install; installed base raven only. Some channels stay unavailable (see: raven channels list)." - & $UvPath tool install --force $wheelUrl + & $UvPath tool install --force @cArgs $wheelUrl if ($LASTEXITCODE -ne 0) { Fail "Raven install failed." } } } diff --git a/install.sh b/install.sh index 89ed6fd..048ec4b 100755 --- a/install.sh +++ b/install.sh @@ -170,12 +170,15 @@ install_raven() { warn "No usable node found; skipping TUI build; raven tui may not work" fi fi + # Pin to the locked dependency set so an install matches what we test. + constraints="$(mktemp)" + uv export --directory "$script_dir" --frozen --all-extras --no-hashes --no-emit-project -o "$constraints" # Install all channel adapters by default. If the umbrella extra fails to # resolve/build on this platform, fall back to base raven so one broken # channel SDK cannot block the whole install. - if ! uv tool install --force -e "$script_dir[channels]"; then + if ! uv tool install --force -c "$constraints" -e "$script_dir[channels]"; then warn "Channel dependencies failed to install; installed base raven only. Some channels stay unavailable (see: raven channels list)." - uv tool install --force -e "$script_dir" + uv tool install --force -c "$constraints" -e "$script_dir" fi else # Remote mode: install the latest published release wheel, which bundles @@ -190,10 +193,30 @@ install_raven() { | grep -oE 'https://[^"]*/raven-[^"]*\.whl' | head -n1)" fi [ -n "$wheel_url" ] || die "Could not resolve the latest raven release wheel from GitHub (check network, or set RAVEN_WHEEL_URL to a wheel URL)." + # Derive the locked-constraints URL from the wheel URL (same release dir) so + # the constraints always match the wheel being installed, including when + # RAVEN_WHEEL_URL pins an older wheel. Missing asset / download failure -> + # install without pinning rather than fail. + constraints_url="${RAVEN_CONSTRAINTS_URL:-}" + if [ -z "$constraints_url" ]; then + case "$wheel_url" in + *.whl) constraints_url="${wheel_url%/*}/raven-constraints.txt" ;; + esac + fi + c_args="" + if [ -n "$constraints_url" ]; then + constraints="$(mktemp)" + if curl -fsSL "$constraints_url" -o "$constraints" 2>/dev/null; then + c_args="-c $constraints" + else + warn "Could not download locked constraints; installing without version pinning." + fi + fi info " installing $wheel_url" - if ! uv tool install --force "raven[channels] @ $wheel_url"; then + # shellcheck disable=SC2086 # $c_args is an intentional word-split option pair. + if ! uv tool install --force $c_args "raven[channels] @ $wheel_url"; then warn "Channel dependencies failed to install; installed base raven only. Some channels stay unavailable (see: raven channels list)." - uv tool install --force "$wheel_url" + uv tool install --force $c_args "$wheel_url" fi fi # Ensure ~/.local/bin (uv tool bin dir) is on PATH for future shells. diff --git a/raven/cli/upgrade_commands.py b/raven/cli/upgrade_commands.py index 60cde1a..c6fe34e 100644 --- a/raven/cli/upgrade_commands.py +++ b/raven/cli/upgrade_commands.py @@ -110,11 +110,36 @@ def main(argv=None): if parent_status != 0: return parent_status + # Pin to the same locked constraints the installer uses. Derive the URL from + # the (already trust-checked) wheel URL so the constraints always match the + # wheel being installed. Missing asset / download failure -> upgrade without + # pinning rather than abort. + constraints_path = None + constraints_url = wheel_url.rsplit("/", 1)[0] + "/raven-constraints.txt" + try: + import os + import socket + import tempfile + import urllib.request + + socket.setdefaulttimeout(30) + fd, constraints_path = tempfile.mkstemp(prefix="raven-constraints-", suffix=".txt") + os.close(fd) + urllib.request.urlretrieve(constraints_url, constraints_path) + except Exception: + print( + "Warning: could not download locked constraints; " + "upgrading without version pinning.", + file=sys.stderr, + ) + constraints_path = None + def install(requirement): - return subprocess.run( - [uv_path, "tool", "install", "--force", requirement], - check=False, - ).returncode + command = [uv_path, "tool", "install", "--force"] + if constraints_path: + command += ["-c", constraints_path] + command.append(requirement) + return subprocess.run(command, check=False).returncode try: channel_status = install(f"raven[channels] @ {wheel_url}") diff --git a/tests/test_cli_upgrade_commands.py b/tests/test_cli_upgrade_commands.py index 41889c4..452ddd5 100644 --- a/tests/test_cli_upgrade_commands.py +++ b/tests/test_cli_upgrade_commands.py @@ -368,6 +368,17 @@ def test_uv_tool_target_rejects_malformed_target_fields( upgrade_commands._uv_tool_target() +@pytest.fixture(autouse=True) +def _stub_constraints_urlretrieve(monkeypatch: pytest.MonkeyPatch) -> None: + # The upgrade helper downloads locked constraints via urllib before running + # uv. Stub it so helper tests never touch the network; default to failure so + # the graceful no-pin path (the pre-constraints command shape) is what the + # existing assertions observe. Tests that exercise pinning override this. + import urllib.request + + monkeypatch.setattr(urllib.request, "urlretrieve", Mock(side_effect=OSError("no network"))) + + def _load_upgrade_helper() -> object: namespace: dict[str, object] = {"__name__": "raven_upgrade_helper_test"} exec(upgrade_commands._UPGRADE_HELPER_SOURCE, namespace) @@ -456,6 +467,62 @@ def test_upgrade_helper_catches_uv_execution_errors( assert "access denied" in capsys.readouterr().err +def test_upgrade_helper_pins_constraints_when_download_succeeds( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import urllib.request + + downloaded: list[tuple[str, str]] = [] + + def fake_urlretrieve(url: str, filename: str) -> tuple[str, None]: + downloaded.append((url, filename)) + return filename, None + + monkeypatch.setattr(urllib.request, "urlretrieve", fake_urlretrieve) + run = Mock(return_value=Mock(returncode=0)) + monkeypatch.setattr(subprocess, "run", run) + helper_main = _load_upgrade_helper() + + status = helper_main(["/usr/bin/uv", WHEEL_URL, "0.1.3", "0.1.4"]) + + assert status == 0 + assert len(downloaded) == 1 + constraints_url, constraints_path = downloaded[0] + assert constraints_url == ("https://github.com/EverMind-AI/Raven/releases/download/v0.1.4/raven-constraints.txt") + run.assert_called_once_with( + [ + "/usr/bin/uv", + "tool", + "install", + "--force", + "-c", + constraints_path, + f"raven[channels] @ {WHEEL_URL}", + ], + check=False, + ) + + +def test_upgrade_helper_skips_constraints_when_download_fails( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + # The autouse stub makes the constraints download fail; the helper must fall + # back to an unpinned install rather than abort. + run = Mock(return_value=Mock(returncode=0)) + monkeypatch.setattr(subprocess, "run", run) + helper_main = _load_upgrade_helper() + + status = helper_main(["/usr/bin/uv", WHEEL_URL, "0.1.3", "0.1.4"]) + + assert status == 0 + run.assert_called_once_with( + ["/usr/bin/uv", "tool", "install", "--force", f"raven[channels] @ {WHEEL_URL}"], + check=False, + ) + assert "upgrading without version pinning" in capsys.readouterr().err + + def test_upgrade_helper_waits_for_parent_before_running_uv( monkeypatch: pytest.MonkeyPatch, ) -> None: