Unify release selection across clients
ci / test (push) Canceled after 0s
ci / fuzz-smoke (push) Canceled after 0s
ci / macos-client (macos-aarch64, macos-14) (push) Canceled after 0s
ci / macos-client (macos-x86_64, macos-13) (push) Canceled after 0s
ci / windows-client (push) Canceled after 0s
ci / package-release (linux-x86_64, ubuntu-latest, , , ) (push) Canceled after 0s
ci / package-release (macos-aarch64, macos-14, , , ) (push) Canceled after 0s
ci / package-release (macos-x86_64, macos-13, , , ) (push) Canceled after 0s
ci / package-release (windows-aarch64, windows-latest, aarch64, windows, aarch64-pc-windows-msvc) (push) Canceled after 0s
ci / package-release (windows-x86_64, windows-latest, , , ) (push) Canceled after 0s
ci / remote-bench (push) Canceled after 0s
ci / publish-gitea-release (push) Canceled after 0s

This commit is contained in:
DuProcess
2026-07-17 18:58:58 -04:00
parent 89b81d73b1
commit 7f8d5711ea
7 changed files with 641 additions and 439 deletions
+3
View File
@@ -88,6 +88,9 @@ jobs:
$errors | ForEach-Object { Write-Error $_ }
exit 1
}
- name: Windows installer runtime test
shell: pwsh
run: scripts/test-install-windows.ps1
- name: cmd installer smoke check
shell: pwsh
run: |
+36 -132
View File
@@ -11,14 +11,17 @@ param(
[string]$BinaryBase = $env:DOSH_BINARY_BASE,
[string]$BinaryName = $env:DOSH_BINARY_NAME,
[string]$BinaryVersion = $(if ($env:DOSH_BINARY_VERSION) { $env:DOSH_BINARY_VERSION } else { "latest" }),
[switch]$BinaryExact = $($env:DOSH_BINARY_EXACT -and $env:DOSH_BINARY_EXACT -ne "0"),
[string]$UpdateCache = $(if ($env:DOSH_UPDATE_CACHE) { $env:DOSH_UPDATE_CACHE } elseif ($env:LOCALAPPDATA) { Join-Path $env:LOCALAPPDATA "dosh\source" } else { Join-Path $HOME ".cache\dosh\source" }),
[switch]$BinaryRequired = $($env:DOSH_BINARY_REQUIRED -and $env:DOSH_BINARY_REQUIRED -ne "0"),
[switch]$FromCurrent,
[switch]$ForceConfig
)
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
$Quiet = $env:DOSH_UPDATE_QUIET -and $env:DOSH_UPDATE_QUIET -ne "0"
$script:ResolvedSourceRef = $null
function Write-Info($Message) {
if (-not $Quiet) {
@@ -121,96 +124,18 @@ function Release-DownloadUrl {
if (-not $web) {
return $null
}
if ($BinaryVersion -eq "latest") {
return "$web/releases/latest/download/$name"
}
"$web/releases/download/$BinaryVersion/$name"
}
function Release-LatestTagDownloadUrl {
if ($BinaryUrl -or $BinaryBase -or $BinaryVersion -ne "latest") {
$releaseRef = Source-ReleaseRef
if (-not $releaseRef) {
return $null
}
$web = Repo-WebBase $Repo
if (-not $web) {
return $null
}
try {
$response = Invoke-WebRequest -UseBasicParsing -Uri "$web/releases/latest" -MaximumRedirection 5
$effective = $null
if ($response.BaseResponse.ResponseUri) {
$effective = $response.BaseResponse.ResponseUri.AbsoluteUri
} elseif ($response.BaseResponse.RequestMessage -and $response.BaseResponse.RequestMessage.RequestUri) {
$effective = $response.BaseResponse.RequestMessage.RequestUri.AbsoluteUri
}
$prefix = "$web/releases/tag/"
if ($effective -and $effective.StartsWith($prefix)) {
$tag = $effective.Substring($prefix.Length)
if ($tag) {
return "$web/releases/download/$tag/$(Release-ArtifactName)"
}
}
}
catch {
return $null
}
return $null
}
function Version-Core($Value) {
$base = $Value.TrimStart("v").Split("+")[0]
$base.Split("-")[0]
}
function Version-Prerelease($Value) {
$base = $Value.TrimStart("v").Split("+")[0]
$dash = $base.IndexOf("-")
if ($dash -lt 0) {
return ""
}
$base.Substring($dash + 1).ToLowerInvariant()
}
function Version-Parts($Value) {
[regex]::Matches((Version-Core $Value), "\d+") | ForEach-Object { [int64]$_.Value }
}
function Compare-Prerelease($Left, $Right) {
if (-not $Left -and -not $Right) {
return 0
}
if (-not $Left) {
return 1
}
if (-not $Right) {
return -1
}
if ($Left -eq $Right) {
return 0
}
if ($Left -match "(?i)^rc(\d+)$" -and $Right -match "(?i)^rc(\d+)$") {
$leftRc = [int64]([regex]::Match($Left, "(?i)^rc(\d+)$").Groups[1].Value)
$rightRc = [int64]([regex]::Match($Right, "(?i)^rc(\d+)$").Groups[1].Value)
return $leftRc.CompareTo($rightRc)
}
return [string]::CompareOrdinal($Left, $Right)
}
function Compare-DoshVersion($Left, $Right) {
$leftParts = @(Version-Parts $Left)
$rightParts = @(Version-Parts $Right)
$width = [Math]::Max($leftParts.Count, $rightParts.Count)
for ($i = 0; $i -lt $width; $i++) {
$l = if ($i -lt $leftParts.Count) { $leftParts[$i] } else { 0 }
$r = if ($i -lt $rightParts.Count) { $rightParts[$i] } else { 0 }
if ($l -lt $r) { return -1 }
if ($l -gt $r) { return 1 }
}
return Compare-Prerelease (Version-Prerelease $Left) (Version-Prerelease $Right)
"$web/releases/download/$releaseRef/$name"
}
function Current-SourceVersion {
if (Test-Path "Cargo.toml") {
if ($FromCurrent) {
if (-not (Test-Path "Cargo.toml")) {
return $null
}
$raw = Get-Content "Cargo.toml" -Raw
} else {
$web = Repo-WebBase $Repo
@@ -230,47 +155,23 @@ function Current-SourceVersion {
return $null
}
function Latest-ReleaseTag {
$web = Repo-WebBase $Repo
if (-not $web) {
function Source-ReleaseRef {
if ($BinaryUrl -or $BinaryBase) {
return $null
}
try {
$response = Invoke-WebRequest -UseBasicParsing -Uri "$web/releases/latest" -MaximumRedirection 5
$effective = $null
if ($response.BaseResponse.ResponseUri) {
$effective = $response.BaseResponse.ResponseUri.AbsoluteUri
} elseif ($response.BaseResponse.RequestMessage -and $response.BaseResponse.RequestMessage.RequestUri) {
$effective = $response.BaseResponse.RequestMessage.RequestUri.AbsoluteUri
}
$prefix = "$web/releases/tag/"
if ($effective -and $effective.StartsWith($prefix)) {
return $effective.Substring($prefix.Length)
}
if ($BinaryVersion -ne "latest" -and $BinaryExact) {
return $BinaryVersion
}
catch {
return $null
$current = Current-SourceVersion
if ($current) {
return "v$($current.TrimStart('v'))"
}
if ($BinaryVersion -ne "latest") {
return $BinaryVersion
}
return $null
}
function Latest-ReleaseIsStale {
if ($BinaryUrl -or $BinaryBase -or $BinaryVersion -ne "latest") {
return $false
}
$latest = Latest-ReleaseTag
$current = Current-SourceVersion
if (-not $latest -or -not $current) {
return $false
}
$latestVersion = $latest.TrimStart("v")
if ((Compare-DoshVersion $latestVersion $current) -lt 0) {
Write-Warning "latest release $latestVersion is older than source $current; skipping stale prebuilt"
return $true
}
return $false
}
function Verify-ArchiveChecksum($Url, $Archive) {
$checksumPath = "$Archive.sha256"
try {
@@ -475,16 +376,11 @@ if ($env:DOSH_INSTALL_BINDIR_FILE) {
Apply-PendingBinaryReplacements $bindir
function Install-Prebuilt {
if (Latest-ReleaseIsStale) {
return $false
}
$url = Release-LatestTagDownloadUrl
if (-not $url) {
$url = Release-DownloadUrl
}
$url = Release-DownloadUrl
if (-not $url) {
return $false
}
$script:ResolvedSourceRef = Source-ReleaseRef
$tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("dosh-bin-" + [guid]::NewGuid())
$zip = Join-Path $tmp (Release-ArtifactName)
$extract = Join-Path $tmp "extract"
@@ -520,7 +416,10 @@ function Install-Prebuilt {
function Install-FromSource {
Ensure-Cargo
if (Test-Path "Cargo.toml") {
if ($FromCurrent) {
if (-not (Test-Path "Cargo.toml")) {
throw "-FromCurrent requires a Dosh checkout with Cargo.toml"
}
$src = (Get-Location).Path
} else {
if (-not $Repo) {
@@ -530,21 +429,26 @@ function Install-FromSource {
$sourceCache = Assert-SafeUpdateCache $UpdateCache
$parent = Split-Path -Parent $sourceCache
New-Item -ItemType Directory -Force -Path $parent | Out-Null
$sourceRef = if ($script:ResolvedSourceRef) { $script:ResolvedSourceRef } else { "main" }
if (Test-Path (Join-Path $sourceCache ".git")) {
git -C $sourceCache remote set-url origin $Repo
$fetchArgs = @("-C", $sourceCache, "fetch", "--depth", "1", "origin", "main")
$fetchArgs = @("-C", $sourceCache, "fetch", "--depth", "1", "origin", $sourceRef)
if ($Quiet) {
$fetchArgs = @("-C", $sourceCache, "fetch", "-q", "--depth", "1", "origin", "main")
$fetchArgs = @("-C", $sourceCache, "fetch", "-q", "--depth", "1", "origin", $sourceRef)
}
git @fetchArgs
git -C $sourceCache checkout -q -B main FETCH_HEAD
if ($sourceRef -eq "main") {
git -C $sourceCache checkout -q -B main FETCH_HEAD
} else {
git -C $sourceCache checkout -q --detach FETCH_HEAD
}
} else {
if (Test-Path $sourceCache) {
Remove-Item -Recurse -Force $sourceCache
}
$cloneArgs = @("clone", "--depth", "1", "--branch", "main", $Repo, $sourceCache)
$cloneArgs = @("clone", "--depth", "1", "--branch", $sourceRef, $Repo, $sourceCache)
if ($Quiet) {
$cloneArgs = @("clone", "-q", "--depth", "1", "--branch", "main", $Repo, $sourceCache)
$cloneArgs = @("clone", "-q", "--depth", "1", "--branch", $sourceRef, $Repo, $sourceCache)
}
git @cloneArgs | Out-Null
}
+38 -122
View File
@@ -17,6 +17,7 @@ binary_url="${DOSH_BINARY_URL:-}"
binary_base="${DOSH_BINARY_BASE:-}"
binary_name="${DOSH_BINARY_NAME:-}"
binary_version="${DOSH_BINARY_VERSION:-latest}"
binary_exact="${DOSH_BINARY_EXACT:-0}"
binary_required="${DOSH_BINARY_REQUIRED:-0}"
usage() {
@@ -47,7 +48,9 @@ Environment alternatives:
DOSH_BINARY_NAME NAME
Release tarball name; defaults to dosh-OS-ARCH.tar.gz
DOSH_BINARY_VERSION TAG
Release tag when deriving DOSH_BINARY_BASE; default latest
Requested release tag; repository version wins unless exact
DOSH_BINARY_EXACT=1
Require DOSH_BINARY_VERSION exactly, including downgrades
DOSH_BINARY_REQUIRED=1
Fail instead of falling back to source when binary install fails
EOF
@@ -173,6 +176,7 @@ config_dir="$HOME/.config/dosh"
data_dir="$HOME/.local/share/dosh"
systemd_user_dir="$HOME/.config/systemd/user"
src_dir=""
resolved_source_ref=""
mkdir -p "$bindir" "$config_dir" "$data_dir"
@@ -228,97 +232,13 @@ release_download_url() {
return 1
fi
web_base="$(repo_web_base "$repo")" || return 1
if [ "$binary_version" = "latest" ]; then
printf '%s/releases/latest/download/%s\n' "$web_base" "$(release_artifact_name)"
else
printf '%s/releases/download/%s/%s\n' "$web_base" "$binary_version" "$(release_artifact_name)"
fi
}
release_latest_tag_download_url() {
if [ -n "$binary_url" ] || [ -n "$binary_base" ] || [ "$binary_version" != "latest" ] || [ -z "$repo" ]; then
return 1
fi
web_base="$(repo_web_base "$repo")" || return 1
latest_url="$(curl -fsSL -o /dev/null -w '%{url_effective}' "$web_base/releases/latest" 2>/dev/null || true)"
case "$latest_url" in
"$web_base"/releases/tag/*)
tag="${latest_url##"$web_base"/releases/tag/}"
[ -n "$tag" ] || return 1
printf '%s/releases/download/%s/%s\n' "$web_base" "$tag" "$(release_artifact_name)"
;;
*)
return 1
;;
esac
}
version_core() {
value="${1#v}"
value="${value%%+*}"
printf '%s\n' "${value%%-*}"
}
version_prerelease() {
value="${1#v}"
value="${value%%+*}"
case "$value" in
*-*) printf '%s\n' "${value#*-}" ;;
*) printf '\n' ;;
esac
}
version_core_part() {
version="$1"
index="$2"
version_core "$version" \
| sed 's/[^0-9][^0-9]*/ /g' \
| awk -v index="$index" '{ value=$index; if (value == "") value=0; print value }'
}
prerelease_less_than() {
left="$1"
right="$2"
if [ -z "$left" ] && [ -z "$right" ]; then
return 1
fi
if [ -z "$left" ]; then
return 1
fi
if [ -z "$right" ]; then
return 0
fi
if [ "$left" = "$right" ]; then
return 1
fi
left_rc="$(printf '%s\n' "$left" | sed -n 's/^[Rr][Cc]\([0-9][0-9]*\)$/\1/p')"
right_rc="$(printf '%s\n' "$right" | sed -n 's/^[Rr][Cc]\([0-9][0-9]*\)$/\1/p')"
if [ -n "$left_rc" ] && [ -n "$right_rc" ]; then
[ "$left_rc" -lt "$right_rc" ]
return
fi
first="$(printf '%s\n%s\n' "$left" "$right" | LC_ALL=C sort | sed -n '1p')"
[ "$first" = "$left" ]
}
version_less_than() {
left="$1"
right="$2"
for index in 1 2 3 4 5 6 7 8; do
lpart="$(version_core_part "$left" "$index")"
rpart="$(version_core_part "$right" "$index")"
if [ "$lpart" -lt "$rpart" ]; then
return 0
fi
if [ "$lpart" -gt "$rpart" ]; then
return 1
fi
done
prerelease_less_than "$(version_prerelease "$left")" "$(version_prerelease "$right")"
release_ref="$(release_source_ref)" || return 1
printf '%s/releases/download/%s/%s\n' "$web_base" "$release_ref" "$(release_artifact_name)"
}
current_source_version() {
if [ -f Cargo.toml ]; then
if [ "$from_current" -eq 1 ]; then
[ -f Cargo.toml ] || return 1
sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | sed -n '1p'
return 0
fi
@@ -329,32 +249,21 @@ current_source_version() {
| sed -n '1p'
}
latest_release_tag() {
[ -n "$repo" ] || return 1
web_base="$(repo_web_base "$repo")" || return 1
latest_url="$(curl -fsSL -o /dev/null -w '%{url_effective}' "$web_base/releases/latest" 2>/dev/null || true)"
case "$latest_url" in
"$web_base"/releases/tag/*)
tag="${latest_url##"$web_base"/releases/tag/}"
[ -n "$tag" ] || return 1
printf '%s\n' "$tag"
;;
*)
return 1
;;
esac
}
latest_release_is_stale() {
if [ -n "$binary_url" ] || [ -n "$binary_base" ] || [ "$binary_version" != "latest" ]; then
release_source_ref() {
if [ -n "$binary_url" ] || [ -n "$binary_base" ]; then
return 1
fi
latest="$(latest_release_tag || true)"
if [ "$binary_version" != "latest" ] && [ "$binary_exact" = "1" ]; then
printf '%s\n' "$binary_version"
return 0
fi
current="$(current_source_version || true)"
[ -n "$latest" ] && [ -n "$current" ] || return 1
latest="${latest#v}"
if version_less_than "$latest" "$current"; then
echo "latest release $latest is older than source $current; skipping stale prebuilt" >&2
if [ -n "$current" ]; then
printf 'v%s\n' "${current#v}"
return 0
fi
if [ "$binary_version" != "latest" ]; then
printf '%s\n' "$binary_version"
return 0
fi
return 1
@@ -450,10 +359,8 @@ verify_archive_version() {
}
try_install_prebuilt() {
if latest_release_is_stale; then
return 1
fi
download_url="$(release_latest_tag_download_url || release_download_url)" || return 1
download_url="$(release_download_url)" || return 1
resolved_source_ref="$(release_source_ref || true)"
tmpdir="$(mktemp -d)"
archive="$tmpdir/$(release_artifact_name)"
checksum_file="$archive.sha256"
@@ -490,7 +397,11 @@ try_install_prebuilt() {
install_from_source() {
ensure_cargo
if [ "$from_current" -eq 1 ] || [ -f Cargo.toml ]; then
if [ "$from_current" -eq 1 ]; then
[ -f Cargo.toml ] || {
echo "--from-current requires a Dosh checkout with Cargo.toml" >&2
exit 2
}
src_dir="$(pwd)"
else
if [ -z "$repo" ]; then
@@ -500,22 +411,27 @@ install_from_source() {
update_cache="$(safe_update_cache_path "$update_cache")"
need git
mkdir -p "$(dirname "$update_cache")"
source_ref="${resolved_source_ref:-main}"
if [ -d "$update_cache/.git" ]; then
[ "$quiet" != "1" ] && echo "Updating Dosh source"
git -C "$update_cache" remote set-url origin "$repo"
if [ "$quiet" = "1" ]; then
git -C "$update_cache" fetch -q --depth 1 origin main
git -C "$update_cache" fetch -q --depth 1 origin "$source_ref"
else
git -C "$update_cache" fetch --depth 1 origin main
git -C "$update_cache" fetch --depth 1 origin "$source_ref"
fi
if [ "$source_ref" = "main" ]; then
git -C "$update_cache" checkout -q -B main FETCH_HEAD
else
git -C "$update_cache" checkout -q --detach FETCH_HEAD
fi
git -C "$update_cache" checkout -q -B main FETCH_HEAD
else
[ "$quiet" != "1" ] && echo "Downloading Dosh source"
rm -rf "$update_cache"
if [ "$quiet" = "1" ]; then
git clone -q --depth 1 --branch main "$repo" "$update_cache" >/dev/null
git clone -q --depth 1 --branch "$source_ref" "$repo" "$update_cache" >/dev/null
else
git clone --depth 1 --branch main "$repo" "$update_cache" >/dev/null
git clone --depth 1 --branch "$source_ref" "$repo" "$update_cache" >/dev/null
fi
fi
src_dir="$update_cache"
+168
View File
@@ -0,0 +1,168 @@
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
$version = "9.8.7-rc3"
$root = Join-Path ([System.IO.Path]::GetTempPath()) ("dosh-installer-test-" + [guid]::NewGuid())
$webRoot = Join-Path $root "web"
$repoRoot = Join-Path $webRoot "repo"
$releaseRoot = Join-Path $repoRoot "releases\download\v$version"
$rawRoot = Join-Path $repoRoot "raw\branch\main"
$stageRoot = Join-Path $root "stage\dosh"
$requestLog = Join-Path $root "requests.log"
$prefix = Join-Path $root "prefix"
$home = Join-Path $root "home"
$unrelated = Join-Path $root "unrelated"
$job = $null
$savedBinaryVersion = $env:DOSH_BINARY_VERSION
$savedBinaryExact = $env:DOSH_BINARY_EXACT
function Invoke-TestInstaller($HomePath, $WorkingDirectory, $InstallPrefix, $Repository) {
$savedHome = $env:HOME
$savedUserProfile = $env:USERPROFILE
try {
$env:HOME = $HomePath
$env:USERPROFILE = $HomePath
Push-Location $WorkingDirectory
try {
$powershell = (Get-Process -Id $PID).Path
& $powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "..\install.ps1") -Role client -Repo $Repository -Prefix $InstallPrefix -BinaryRequired
if ($LASTEXITCODE -ne 0) {
throw "Windows installer exited with status $LASTEXITCODE"
}
}
finally {
Pop-Location
}
}
finally {
$env:HOME = $savedHome
$env:USERPROFILE = $savedUserProfile
}
}
try {
New-Item -ItemType Directory -Force -Path $releaseRoot, $rawRoot, (Join-Path $stageRoot "bin"), $home, $unrelated | Out-Null
@"
[package]
name = "dosh"
version = "$version"
"@ | Set-Content -Encoding ascii -NoNewline (Join-Path $rawRoot "Cargo.toml")
@"
[package]
name = "not-dosh"
version = "99.0.0"
"@ | Set-Content -Encoding ascii -NoNewline (Join-Path $unrelated "Cargo.toml")
"$version`n" | Set-Content -Encoding ascii -NoNewline (Join-Path $stageRoot "VERSION")
"fixture" | Set-Content -Encoding ascii -NoNewline (Join-Path $stageRoot "bin\dosh-client.exe")
$archive = Join-Path $releaseRoot "dosh-windows-x86_64.zip"
Compress-Archive -Force -Path $stageRoot -DestinationPath $archive
$digest = (Get-FileHash -Algorithm SHA256 $archive).Hash.ToLowerInvariant()
"$digest dosh-windows-x86_64.zip`n" | Set-Content -Encoding ascii -NoNewline "$archive.sha256"
$probe = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
$probe.Start()
$port = ([System.Net.IPEndPoint]$probe.LocalEndpoint).Port
$probe.Stop()
$listenPrefix = "http://127.0.0.1:$port/"
$job = Start-Job -ArgumentList $webRoot, $listenPrefix, $requestLog -ScriptBlock {
param($Root, $Prefix, $Log)
$listener = [System.Net.HttpListener]::new()
$listener.Prefixes.Add($Prefix)
$listener.Start()
try {
while ($listener.IsListening) {
$context = $listener.GetContext()
$path = [System.Uri]::UnescapeDataString($context.Request.Url.AbsolutePath.TrimStart('/'))
Add-Content -Encoding ascii -Path $Log -Value $context.Request.Url.AbsolutePath
$file = Join-Path $Root ($path.Replace('/', [System.IO.Path]::DirectorySeparatorChar))
if (Test-Path -LiteralPath $file -PathType Leaf) {
$bytes = [System.IO.File]::ReadAllBytes($file)
$context.Response.StatusCode = 200
$context.Response.ContentLength64 = $bytes.Length
if ($context.Request.HttpMethod -ne "HEAD") {
$context.Response.OutputStream.Write($bytes, 0, $bytes.Length)
}
} else {
$context.Response.StatusCode = 404
}
$context.Response.Close()
}
}
finally {
$listener.Stop()
}
}
$ready = $false
for ($attempt = 0; $attempt -lt 50; $attempt++) {
try {
Invoke-WebRequest -UseBasicParsing -Uri "${listenPrefix}repo/raw/branch/main/Cargo.toml" | Out-Null
$ready = $true
break
}
catch {
Start-Sleep -Milliseconds 100
}
}
if (-not $ready) {
throw "installer fixture HTTP server did not start"
}
$env:DOSH_BINARY_VERSION = "v0.1.17"
Remove-Item Env:DOSH_BINARY_EXACT -ErrorAction SilentlyContinue
Invoke-TestInstaller $home $unrelated $prefix "${listenPrefix}repo.git"
if (-not (Test-Path (Join-Path $prefix "bin\dosh-client.exe"))) {
throw "Windows installer did not install dosh-client.exe"
}
$requests = Get-Content $requestLog -Raw
if ($requests -notmatch "/repo/raw/branch/main/Cargo.toml") {
throw "Windows installer did not read the repository release version"
}
if ($requests -notmatch "/repo/releases/download/v$([regex]::Escape($version))/dosh-windows-x86_64.zip") {
throw "Windows installer did not request the repository-declared release artifact"
}
if ($requests -match "/releases/latest" -or $requests -match "99.0.0") {
throw "Windows installer used a stable redirect or unrelated Cargo.toml"
}
"fixture-v2" | Set-Content -Encoding ascii -NoNewline (Join-Path $stageRoot "bin\dosh-client.exe")
Compress-Archive -Force -Path $stageRoot -DestinationPath $archive
$digest = (Get-FileHash -Algorithm SHA256 $archive).Hash.ToLowerInvariant()
"$digest dosh-windows-x86_64.zip`n" | Set-Content -Encoding ascii -NoNewline "$archive.sha256"
$installedAlias = Join-Path $prefix "bin\dosh.exe"
$lock = [System.IO.File]::Open($installedAlias, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::None)
try {
Invoke-TestInstaller $home $unrelated $prefix "${listenPrefix}repo.git"
$pending = @(Get-ChildItem (Join-Path $prefix "bin") -Filter "dosh.exe.pending.*")
if ($pending.Count -ne 1) {
throw "locked Windows executable was not staged exactly once"
}
}
finally {
$lock.Dispose()
}
$replaced = $false
for ($attempt = 0; $attempt -lt 100; $attempt++) {
if ((Get-Content $installedAlias -Raw) -eq "fixture-v2" -and -not (Get-ChildItem (Join-Path $prefix "bin") -Filter "dosh.exe.pending.*")) {
$replaced = $true
break
}
Start-Sleep -Milliseconds 100
}
if (-not $replaced) {
throw "staged Windows executable was not applied after its lock was released"
}
Write-Host "Windows installer runtime test passed"
}
finally {
$env:DOSH_BINARY_VERSION = $savedBinaryVersion
$env:DOSH_BINARY_EXACT = $savedBinaryExact
if ($job) {
Stop-Job $job -ErrorAction SilentlyContinue
Remove-Job $job -Force -ErrorAction SilentlyContinue
}
Remove-Item -Recurse -Force $root -ErrorAction SilentlyContinue
}
+105 -162
View File
@@ -4313,27 +4313,6 @@ fn update_installer_url(raw_base: &str, os: &str) -> String {
format!("{raw_base}/raw/branch/main/{}", update_installer_name(os))
}
fn latest_release_download_url(repo: &str, artifact: &str) -> Option<String> {
let web = repo
.strip_suffix(".git")
.unwrap_or(repo)
.trim_end_matches('/');
if !web.starts_with("http://") && !web.starts_with("https://") {
return None;
}
Some(format!("{web}/releases/latest/download/{artifact}"))
}
fn release_tag_from_effective_url(web: &str, effective: &str) -> Option<String> {
let prefix = format!("{web}/releases/tag/");
let tag = effective.strip_prefix(&prefix)?;
if tag.is_empty() {
None
} else {
Some(tag.to_string())
}
}
fn release_version_from_tag(tag: &str) -> &str {
tag.strip_prefix('v').unwrap_or(tag)
}
@@ -4346,20 +4325,6 @@ fn update_version_status(local: &str, latest: &str) -> &'static str {
}
}
fn update_binary_version_for_installer(local: &str, latest_tag: Option<&str>) -> Option<String> {
let latest = latest_tag.map(release_version_from_tag)?;
if compare_dotted_versions(latest, local) == std::cmp::Ordering::Less {
Some(format!("v{local}"))
} else {
None
}
}
fn effective_update_artifact_tag(local: &str, latest_tag: Option<&str>) -> Option<String> {
update_binary_version_for_installer(local, latest_tag)
.or_else(|| latest_tag.map(ToOwned::to_owned))
}
fn compare_dotted_versions(left: &str, right: &str) -> std::cmp::Ordering {
let left = parse_comparable_version(left);
let right = parse_comparable_version(right);
@@ -4447,7 +4412,7 @@ fn parse_dotted_version(version: &str) -> Vec<u64> {
.collect()
}
fn latest_release_tag(repo: &str) -> Result<Option<String>> {
fn repository_release_tag(repo: &str) -> Result<Option<String>> {
let web = repo
.strip_suffix(".git")
.unwrap_or(repo)
@@ -4455,28 +4420,30 @@ fn latest_release_tag(repo: &str) -> Result<Option<String>> {
if !web.starts_with("http://") && !web.starts_with("https://") {
return Ok(None);
}
let latest_url = format!("{web}/releases/latest");
let manifest_url = format!("{web}/raw/branch/main/Cargo.toml");
let output = if cfg!(windows) {
let script = windows_effective_url_script(&latest_url);
windows_powershell_output(&script, &format!("resolve latest release for {web}"))?
let script = windows_url_contents_script(&manifest_url);
windows_powershell_output(&script, &format!("read release version from {web}"))?
} else {
Command::new("curl")
.arg("-fsSL")
.arg("-o")
.arg("/dev/null")
.arg("-w")
.arg("%{url_effective}")
.arg(latest_url)
.arg(manifest_url)
.stdin(Stdio::null())
.output()
.with_context(|| format!("resolve latest release for {web}"))?
.with_context(|| format!("read release version from {web}"))?
};
if !output.status.success() {
return Ok(None);
}
let effective = String::from_utf8_lossy(&output.stdout);
let effective = effective.trim();
Ok(release_tag_from_effective_url(web, effective))
let manifest =
String::from_utf8(output.stdout).context("repository Cargo.toml is not UTF-8")?;
let parsed: toml::Value = toml::from_str(&manifest).context("parse repository Cargo.toml")?;
let version = parsed
.get("package")
.and_then(|package| package.get("version"))
.and_then(toml::Value::as_str)
.filter(|version| !version.is_empty());
Ok(version.map(|version| format!("v{}", version.trim_start_matches('v'))))
}
fn release_tag_download_url(repo: &str, tag: &str, artifact: &str) -> Option<String> {
@@ -4524,7 +4491,7 @@ fn run_update(
let update_remote_server = options.role.updates_remote_server_from_os(os);
if options.check_only {
let local_version = env!("CARGO_PKG_VERSION");
let latest_tag = latest_release_tag(&repo)?;
let release_tag = repository_release_tag(&repo)?;
println!("dosh {local_version}");
println!("repo: {repo}");
if let Some(local_installer_role) = local_installer_role {
@@ -4538,7 +4505,7 @@ fn run_update(
);
println!("remote_server: {}", config.server);
}
match latest_tag.as_deref() {
match release_tag.as_deref() {
Some(tag) => {
let latest_version = release_version_from_tag(tag);
println!(
@@ -4553,22 +4520,14 @@ fn run_update(
update_remote_server,
&repo,
&artifact,
release_tag.as_deref(),
) {
LocalPrebuiltCheckTarget::Url(latest_url) => {
let mut status = "missing";
let mut display_url = latest_url.clone();
if let Some(tag_url) =
effective_update_artifact_tag(local_version, latest_tag.as_deref())
.as_deref()
.and_then(|tag| release_tag_download_url(&repo, tag, &artifact))
{
display_url = tag_url;
if url_reachable(&display_url)? {
status = "available";
}
} else if url_reachable(&latest_url)? {
status = "available";
}
LocalPrebuiltCheckTarget::Url(display_url) => {
let status = if url_reachable(&display_url)? {
"available"
} else {
"missing; source fallback will build the same tag"
};
println!("prebuilt: {status} ({artifact})");
println!("prebuilt_url: {display_url}");
}
@@ -4578,6 +4537,9 @@ fn run_update(
LocalPrebuiltCheckTarget::RemoteOnly => {
println!("prebuilt: skipped for remote-only update");
}
LocalPrebuiltCheckTarget::ReleaseUnknown => {
println!("prebuilt: unavailable; repository release version is unknown");
}
}
return Ok(());
}
@@ -4586,10 +4548,7 @@ fn run_update(
.join("dosh")
.join("source");
let use_prebuilt = std::env::var("DOSH_USE_PREBUILT").unwrap_or_else(|_| "1".to_string());
let binary_version = update_binary_version_for_installer(
env!("CARGO_PKG_VERSION"),
latest_release_tag(&repo)?.as_deref(),
);
let binary_version = repository_release_tag(&repo)?;
if update_remote_server {
run_remote_server_update(
config,
@@ -4619,6 +4578,7 @@ enum LocalPrebuiltCheckTarget {
Url(String),
NonHttpRepo,
RemoteOnly,
ReleaseUnknown,
}
fn local_prebuilt_check_target(
@@ -4626,6 +4586,7 @@ fn local_prebuilt_check_target(
update_remote_server: bool,
repo: &str,
artifact: &str,
release_tag: Option<&str>,
) -> LocalPrebuiltCheckTarget {
if local_installer_role.is_none() {
return if update_remote_server {
@@ -4634,7 +4595,10 @@ fn local_prebuilt_check_target(
LocalPrebuiltCheckTarget::NonHttpRepo
};
}
latest_release_download_url(repo, artifact)
let Some(release_tag) = release_tag else {
return LocalPrebuiltCheckTarget::ReleaseUnknown;
};
release_tag_download_url(repo, release_tag, artifact)
.map(LocalPrebuiltCheckTarget::Url)
.unwrap_or(LocalPrebuiltCheckTarget::NonHttpRepo)
}
@@ -4727,6 +4691,7 @@ fn unix_update_script(
);
if let Some(version) = binary_version {
env.push_str(&format!(" DOSH_BINARY_VERSION={}", shell_word(version)));
env.push_str(" DOSH_BINARY_EXACT=1");
}
let mut script = format!(
"curl -fsSL {} | {} sh -s -- {}",
@@ -4758,6 +4723,7 @@ fn remote_unix_server_update_script(
);
if let Some(version) = binary_version {
env.push_str(&format!(" DOSH_BINARY_VERSION={}", shell_word(version)));
env.push_str(" DOSH_BINARY_EXACT=1");
}
format!(
"curl -fsSL {} | {} sh -s -- server",
@@ -4795,6 +4761,7 @@ fn windows_update_script(
"$env:DOSH_BINARY_VERSION={};",
powershell_string(version)
));
script.push_str("$env:DOSH_BINARY_EXACT='1';");
}
if config.server != "user@example.com" {
script.push_str(&format!(
@@ -4809,19 +4776,12 @@ fn windows_update_script(
script
}
fn windows_effective_url_script(url: &str) -> String {
fn windows_url_contents_script(url: &str) -> String {
let url = powershell_string(url);
format!(
"$ErrorActionPreference='Stop';\
$ProgressPreference='SilentlyContinue';\
$response=Invoke-WebRequest -UseBasicParsing -Uri {url} -MaximumRedirection 5;\
if ($response.BaseResponse.ResponseUri) {{ \
$response.BaseResponse.ResponseUri.AbsoluteUri \
}} elseif ($response.BaseResponse.RequestMessage -and $response.BaseResponse.RequestMessage.RequestUri) {{ \
$response.BaseResponse.RequestMessage.RequestUri.AbsoluteUri \
}} else {{ \
{url} \
}}"
(Invoke-WebRequest -UseBasicParsing -Uri {url} -MaximumRedirection 5).Content"
)
}
@@ -10950,39 +10910,38 @@ mod tests {
SshPathTokenContext, StartupGateMode, StatusAction, TERMINAL_CLEANUP,
TERMINAL_SNAPSHOT_RESET, UpdateOptions, UpdateRole, auth_allows, cache_key,
cache_server_prefix, cleanup_stream_state, clear_cached_credentials,
effective_update_artifact_tag, ensure_tui_safe_status_overlay, expand_ssh_path_tokens,
first_resolved_addr, imported_host_block, input_contains_focus_in, input_matches_escape,
is_local_status_target, is_resume_response_for_client, latest_release_download_url,
load_first_native_identity_with_prompt, local_symlink_target_is_dir,
local_username_from_env, native_proxy_udp_warning, newest_client_trace_path_from,
note_authenticated_contact, note_snapshot_rendered, parse_dynamic_forward,
parse_escape_key, parse_local_forward, parse_remote_forward, parse_single_remote_path,
parse_ssh_config, parse_trace_line, parse_trace_options, parse_trace_report_options,
parse_trace_summary, parse_update_options, post_submit_hold_duration,
queue_or_send_stream_data, queue_pending_user_input, queue_stale_pending_user_input,
raw_contains_host_table, recv_response_until, refresh_live_addr, release_artifact_name_for,
release_tag_download_url, release_tag_from_effective_url, release_version_from_tag,
remote_unix_server_update_script, render_frame_bytes, render_status_overlay, requested_env,
resolve_forward_agent_endpoint, resolved_startup_command, retire_stream_state,
retransmit_stream_closes, retransmit_stream_eofs, retransmit_stream_opens,
retransmit_stream_window_adjusts, rewrite_forward_command, sanitize_trace_name,
selected_predict_mode, selected_udp_host, send_stream_eof, server_version_mismatch,
should_flush_terminal_input_after_contact, should_health_log_client_start,
should_hold_during_startup_gate, should_hold_post_submit_input,
should_reconnect_before_input_for_local_sleep, should_repaint_idle_terminal,
should_strip_unowned_terminal_reports, socket_bind_display, split_after_command_submit,
split_forward_spec, split_trace_tokens, ssh_command_target, ssh_config_uses_proxy,
ssh_config_word_for_os, ssh_destination_host, ssh_username, ssh_with_user, startup_command,
status_ssh_target, strip_stale_mouse_reports, strip_terminal_focus_reports,
strip_unowned_terminal_reports, summarize_trace_file, summarize_trace_file_with_mode,
terminal_private_mode_transition, toml_bare_key_or_quoted, top_trace_events,
trace_report_warnings, unix_update_script, update_binary_version_for_installer,
update_installer_url, update_version_status, upsert_managed_block, valid_forward_host,
vscode_command_candidates, vscode_fallback_command, vscode_safe_alias,
wake_repaint_retry_deadline, windows_command_word, windows_deferred_update_script,
windows_effective_url_script, windows_mode_from_readonly,
ensure_tui_safe_status_overlay, expand_ssh_path_tokens, first_resolved_addr,
imported_host_block, input_contains_focus_in, input_matches_escape, is_local_status_target,
is_resume_response_for_client, load_first_native_identity_with_prompt,
local_symlink_target_is_dir, local_username_from_env, native_proxy_udp_warning,
newest_client_trace_path_from, note_authenticated_contact, note_snapshot_rendered,
parse_dynamic_forward, parse_escape_key, parse_local_forward, parse_remote_forward,
parse_single_remote_path, parse_ssh_config, parse_trace_line, parse_trace_options,
parse_trace_report_options, parse_trace_summary, parse_update_options,
post_submit_hold_duration, queue_or_send_stream_data, queue_pending_user_input,
queue_stale_pending_user_input, raw_contains_host_table, recv_response_until,
refresh_live_addr, release_artifact_name_for, release_tag_download_url,
release_version_from_tag, remote_unix_server_update_script, render_frame_bytes,
render_status_overlay, requested_env, resolve_forward_agent_endpoint,
resolved_startup_command, retire_stream_state, retransmit_stream_closes,
retransmit_stream_eofs, retransmit_stream_opens, retransmit_stream_window_adjusts,
rewrite_forward_command, sanitize_trace_name, selected_predict_mode, selected_udp_host,
send_stream_eof, server_version_mismatch, should_flush_terminal_input_after_contact,
should_health_log_client_start, should_hold_during_startup_gate,
should_hold_post_submit_input, should_reconnect_before_input_for_local_sleep,
should_repaint_idle_terminal, should_strip_unowned_terminal_reports, socket_bind_display,
split_after_command_submit, split_forward_spec, split_trace_tokens, ssh_command_target,
ssh_config_uses_proxy, ssh_config_word_for_os, ssh_destination_host, ssh_username,
ssh_with_user, startup_command, status_ssh_target, strip_stale_mouse_reports,
strip_terminal_focus_reports, strip_unowned_terminal_reports, summarize_trace_file,
summarize_trace_file_with_mode, terminal_private_mode_transition, toml_bare_key_or_quoted,
top_trace_events, trace_report_warnings, unix_update_script, update_installer_url,
update_version_status, upsert_managed_block, valid_forward_host, vscode_command_candidates,
vscode_fallback_command, vscode_safe_alias, wake_repaint_retry_deadline,
windows_command_word, windows_deferred_update_script, windows_mode_from_readonly,
windows_powershell_command_candidates, windows_readonly_from_mode, windows_update_script,
windows_url_reachable_script, windows_vt_input_mode, windows_vt_output_mode,
windows_url_contents_script, windows_url_reachable_script, windows_vt_input_mode,
windows_vt_output_mode,
};
use dosh::config::{ClientConfig, CommandExtension, HostConfig};
use dosh::native::EnvVar;
@@ -13421,9 +13380,10 @@ mod tests {
false,
"https://git.palav.dev/Palav/dosh.git",
"dosh-windows-x86_64.zip",
Some("v1.0.0-rc42"),
),
super::LocalPrebuiltCheckTarget::Url(
"https://git.palav.dev/Palav/dosh/releases/latest/download/dosh-windows-x86_64.zip"
"https://git.palav.dev/Palav/dosh/releases/download/v1.0.0-rc42/dosh-windows-x86_64.zip"
.to_string()
)
);
@@ -13433,6 +13393,7 @@ mod tests {
false,
"git@git.palav.dev:Palav/dosh.git",
"dosh-windows-x86_64.zip",
Some("v1.0.0-rc42"),
),
super::LocalPrebuiltCheckTarget::NonHttpRepo
);
@@ -13442,9 +13403,20 @@ mod tests {
UpdateRole::Server.updates_remote_server_from_os("windows"),
"https://git.palav.dev/Palav/dosh.git",
"dosh-windows-x86_64.zip",
Some("v1.0.0-rc42"),
),
super::LocalPrebuiltCheckTarget::RemoteOnly
);
assert_eq!(
super::local_prebuilt_check_target(
Some("client"),
false,
"https://git.palav.dev/Palav/dosh.git",
"dosh-windows-x86_64.zip",
None,
),
super::LocalPrebuiltCheckTarget::ReleaseUnknown
);
}
#[test]
@@ -13552,12 +13524,13 @@ mod tests {
#[test]
fn windows_update_probes_are_powershell_native() {
let latest =
windows_effective_url_script("https://git.palav.dev/Palav/dosh/releases/latest");
assert!(latest.contains("Invoke-WebRequest -UseBasicParsing"));
assert!(latest.contains("-MaximumRedirection 5"));
assert!(latest.contains("$response.BaseResponse.ResponseUri.AbsoluteUri"));
assert!(!latest.contains("curl"));
let manifest = windows_url_contents_script(
"https://git.palav.dev/Palav/dosh/raw/branch/main/Cargo.toml",
);
assert!(manifest.contains("Invoke-WebRequest -UseBasicParsing"));
assert!(manifest.contains("-MaximumRedirection 5"));
assert!(manifest.contains(".Content"));
assert!(!manifest.contains("curl"));
let reachable = windows_url_reachable_script(
"https://git.palav.dev/Palav/dosh/releases/latest/download/dosh-windows-x86_64.zip",
@@ -13578,36 +13551,7 @@ mod tests {
}
#[test]
fn update_uses_local_tag_when_release_latest_is_older() {
assert_eq!(
update_binary_version_for_installer("1.0.0-rc41", Some("v1.0.0-rc37")).as_deref(),
Some("v1.0.0-rc41")
);
assert_eq!(
update_binary_version_for_installer("1.0.0-rc41", Some("v1.0.0-rc41")),
None
);
assert_eq!(
update_binary_version_for_installer("1.0.0-rc41", Some("v1.0.0-rc42")),
None
);
assert_eq!(
update_binary_version_for_installer("1.0.0", Some("v1.0.0-rc42")).as_deref(),
Some("v1.0.0")
);
assert_eq!(
update_binary_version_for_installer("1.0.0-rc42", Some("v1.0.0")),
None
);
assert_eq!(
effective_update_artifact_tag("1.0.0-rc41", Some("v1.0.0-rc37")).as_deref(),
Some("v1.0.0-rc41")
);
assert_eq!(
effective_update_artifact_tag("1.0.0-rc41", Some("v1.0.0-rc42")).as_deref(),
Some("v1.0.0-rc42")
);
assert_eq!(effective_update_artifact_tag("1.0.0-rc41", None), None);
fn update_pins_the_repository_release_for_every_platform() {
let config = ClientConfig::default();
let unix = unix_update_script(
&config,
@@ -13619,6 +13563,7 @@ mod tests {
Some("v1.0.0-rc41"),
);
assert!(unix.contains("DOSH_BINARY_VERSION='v1.0.0-rc41'"));
assert!(unix.contains("DOSH_BINARY_EXACT=1"));
assert!(unix.contains("| DOSH_REPO="));
assert!(unix.contains(" sh -s -- 'both'"));
@@ -13632,6 +13577,7 @@ mod tests {
Some("v1.0.0-rc41"),
);
assert!(windows.contains("$env:DOSH_BINARY_VERSION='v1.0.0-rc41';"));
assert!(windows.contains("$env:DOSH_BINARY_EXACT='1';"));
}
#[test]
@@ -13687,17 +13633,23 @@ mod tests {
}
#[test]
fn release_download_url_uses_latest_release_asset() {
fn release_download_url_uses_explicit_repository_release() {
assert_eq!(
latest_release_download_url(
release_tag_download_url(
"https://git.palav.dev/Palav/dosh.git",
"v1.0.0-rc42",
"dosh-linux-x86_64.tar.gz"
)
.unwrap(),
"https://git.palav.dev/Palav/dosh/releases/latest/download/dosh-linux-x86_64.tar.gz"
"https://git.palav.dev/Palav/dosh/releases/download/v1.0.0-rc42/dosh-linux-x86_64.tar.gz"
);
assert!(
latest_release_download_url("git@git.palav.dev:Palav/dosh.git", "artifact").is_none()
release_tag_download_url(
"git@git.palav.dev:Palav/dosh.git",
"v1.0.0-rc42",
"artifact"
)
.is_none()
);
}
@@ -13726,15 +13678,7 @@ mod tests {
}
#[test]
fn release_tag_parses_effective_latest_url() {
assert_eq!(
release_tag_from_effective_url(
"https://example.com/owner/dosh",
"https://example.com/owner/dosh/releases/tag/v1.0.0"
)
.as_deref(),
Some("v1.0.0")
);
fn release_tag_builds_exact_artifact_url() {
assert_eq!(release_version_from_tag("v1.0.0"), "1.0.0");
assert_eq!(release_version_from_tag("2026.06.28"), "2026.06.28");
assert_eq!(
@@ -13748,7 +13692,6 @@ mod tests {
"https://example.com/owner/dosh/releases/download/v1.0.0/dosh-linux-x86_64.tar.gz"
)
);
assert!(release_tag_from_effective_url("https://example.com/owner/dosh", "").is_none());
}
#[test]
+265
View File
@@ -0,0 +1,265 @@
#![cfg(unix)]
use sha2::{Digest, Sha256};
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::TempDir;
const VERSION: &str = "9.8.7-rc3";
fn write_executable(path: &Path, body: &str) {
fs::write(path, body).unwrap();
let mut permissions = fs::metadata(path).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(path, permissions).unwrap();
}
struct InstallerFixture {
root: TempDir,
fake_bin: PathBuf,
archive: PathBuf,
checksum: PathBuf,
curl_log: PathBuf,
git_log: PathBuf,
version: String,
}
impl InstallerFixture {
fn new(version: &str) -> Self {
let root = tempfile::tempdir().unwrap();
let fake_bin = root.path().join("fake-bin");
let stage = root.path().join("stage/dosh");
fs::create_dir_all(fake_bin.as_path()).unwrap();
fs::create_dir_all(stage.join("bin")).unwrap();
fs::write(stage.join("VERSION"), format!("{version}\n")).unwrap();
write_executable(
&stage.join("bin/dosh-client"),
"#!/bin/sh\nprintf 'fixture dosh\\n'\n",
);
let archive = root.path().join("dosh-macos-aarch64.tar.gz");
let status = Command::new("tar")
.args(["-C", root.path().join("stage").to_str().unwrap(), "-czf"])
.arg(&archive)
.arg("dosh")
.status()
.unwrap();
assert!(status.success());
let digest = Sha256::digest(fs::read(&archive).unwrap());
let checksum = root.path().join("dosh-macos-aarch64.tar.gz.sha256");
fs::write(
&checksum,
format!("{digest:x} dosh-macos-aarch64.tar.gz\n"),
)
.unwrap();
write_executable(
&fake_bin.join("uname"),
"#!/bin/sh\ncase \"${1:-}\" in -s) echo Darwin;; -m) echo arm64;; *) echo Darwin;; esac\n",
);
let curl_log = root.path().join("curl.log");
let git_log = root.path().join("git.log");
write_executable(
&fake_bin.join("curl"),
r#"#!/bin/sh
printf '%s\n' "$*" >>"$TEST_CURL_LOG"
out=
previous=
url=
for arg in "$@"; do
if [ "$previous" = "-o" ]; then out="$arg"; fi
previous="$arg"
case "$arg" in http://*|https://*) url="$arg";; esac
done
case "$url" in
*/raw/branch/main/Cargo.toml)
printf '[package]\nname = "dosh"\nversion = "%s"\n' "$TEST_RELEASE_VERSION"
;;
*.sha256)
cp "$TEST_ARCHIVE_CHECKSUM" "$out"
;;
*/releases/download/*)
[ "${TEST_FAIL_ARCHIVE:-0}" = 1 ] && exit 22
cp "$TEST_ARCHIVE" "$out"
;;
*) exit 22;;
esac
"#,
);
Self {
root,
fake_bin,
archive,
checksum,
curl_log,
git_log,
version: version.to_string(),
}
}
fn command(&self, cwd: &Path) -> Command {
let mut command = Command::new("sh");
command
.arg(format!("{}/install.sh", env!("CARGO_MANIFEST_DIR")))
.args([
"client",
"--repo",
"https://example.invalid/Palav/dosh.git",
"--prefix",
])
.arg(self.root.path().join("prefix"))
.current_dir(cwd)
.env("HOME", self.root.path().join("home"))
.env(
"PATH",
format!(
"{}:{}",
self.fake_bin.display(),
std::env::var("PATH").unwrap_or_default()
),
)
.env("DOSH_BINARY_REQUIRED", "1")
.env("TEST_RELEASE_VERSION", &self.version)
.env("TEST_ARCHIVE", &self.archive)
.env("TEST_ARCHIVE_CHECKSUM", &self.checksum)
.env("TEST_CURL_LOG", &self.curl_log)
.env("TEST_GIT_LOG", &self.git_log);
command
}
fn installed_client(&self) -> PathBuf {
self.root.path().join("prefix/bin/dosh-client")
}
}
#[test]
fn unix_installer_uses_repository_version_even_inside_an_unrelated_rust_project() {
let fixture = InstallerFixture::new(VERSION);
let unrelated = fixture.root.path().join("unrelated");
fs::create_dir_all(&unrelated).unwrap();
fs::write(
unrelated.join("Cargo.toml"),
"[package]\nname = \"not-dosh\"\nversion = \"99.0.0\"\n",
)
.unwrap();
let output = fixture.command(&unrelated).output().unwrap();
assert!(
output.status.success(),
"installer failed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(fixture.installed_client().is_file());
let requests = fs::read_to_string(&fixture.curl_log).unwrap();
assert!(requests.contains("/raw/branch/main/Cargo.toml"));
assert!(requests.contains(&format!(
"/releases/download/v{VERSION}/dosh-macos-aarch64.tar.gz"
)));
assert!(!requests.contains("/releases/latest"));
assert!(!requests.contains("99.0.0"));
}
#[test]
fn unix_installer_source_fallback_checks_out_the_same_release_tag() {
let fixture = InstallerFixture::new(VERSION);
write_executable(
&fixture.fake_bin.join("git"),
r#"#!/bin/sh
printf '%s\n' "$*" >>"$TEST_GIT_LOG"
if [ "${1:-}" = clone ]; then
for destination in "$@"; do :; done
mkdir -p "$destination/.git"
fi
"#,
);
write_executable(
&fixture.fake_bin.join("cargo"),
"#!/bin/sh\nmkdir -p target/release\nprintf '#!/bin/sh\\n' >target/release/dosh-client\nchmod +x target/release/dosh-client\n",
);
let cwd = fixture.root.path().join("plain");
fs::create_dir_all(&cwd).unwrap();
let mut command = fixture.command(&cwd);
command
.env("DOSH_BINARY_REQUIRED", "0")
.env("TEST_FAIL_ARCHIVE", "1");
let output = command.output().unwrap();
assert!(
output.status.success(),
"installer failed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(fixture.installed_client().is_file());
let git = fs::read_to_string(&fixture.git_log).unwrap();
assert!(git.contains(&format!("clone --depth 1 --branch v{VERSION}")));
assert!(!git.contains("--branch main"));
}
#[test]
fn unix_installer_resolves_a_stable_repository_version_without_latest_redirects() {
let version = "1.2.3";
let fixture = InstallerFixture::new(version);
let cwd = fixture.root.path().join("plain");
fs::create_dir_all(&cwd).unwrap();
let output = fixture.command(&cwd).output().unwrap();
assert!(
output.status.success(),
"installer failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let requests = fs::read_to_string(&fixture.curl_log).unwrap();
assert!(requests.contains(&format!(
"/releases/download/v{version}/dosh-macos-aarch64.tar.gz"
)));
assert!(!requests.contains("/releases/latest"));
}
#[test]
fn unix_installer_repairs_a_legacy_updater_that_requests_its_old_tag() {
let fixture = InstallerFixture::new(VERSION);
let cwd = fixture.root.path().join("plain");
fs::create_dir_all(&cwd).unwrap();
let mut command = fixture.command(&cwd);
command.env("DOSH_BINARY_VERSION", "v0.1.17");
let output = command.output().unwrap();
assert!(
output.status.success(),
"installer failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let requests = fs::read_to_string(&fixture.curl_log).unwrap();
assert!(requests.contains(&format!(
"/releases/download/v{VERSION}/dosh-macos-aarch64.tar.gz"
)));
assert!(!requests.contains("/releases/download/v0.1.17/"));
}
#[test]
fn unix_installer_honors_an_explicit_exact_release_pin() {
let pinned = "0.1.17";
let fixture = InstallerFixture::new(pinned);
let cwd = fixture.root.path().join("plain");
fs::create_dir_all(&cwd).unwrap();
let mut command = fixture.command(&cwd);
command
.env("TEST_RELEASE_VERSION", VERSION)
.env("DOSH_BINARY_VERSION", format!("v{pinned}"))
.env("DOSH_BINARY_EXACT", "1");
let output = command.output().unwrap();
assert!(
output.status.success(),
"installer failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let requests = fs::read_to_string(&fixture.curl_log).unwrap();
assert!(requests.contains(&format!(
"/releases/download/v{pinned}/dosh-macos-aarch64.tar.gz"
)));
assert!(!requests.contains(&format!("/releases/download/v{VERSION}/")));
}
+26 -23
View File
@@ -94,6 +94,8 @@ fn release_gates_test_all_targets() {
assert!(windows_client.contains("runs-on: windows-latest"));
assert!(windows_client.contains("name: Test Windows client"));
assert!(windows_client.contains("name: PowerShell syntax check"));
assert!(windows_client.contains("name: Windows installer runtime test"));
assert!(windows_client.contains("run: scripts/test-install-windows.ps1"));
assert!(windows_client.contains("shell: pwsh"));
assert!(
windows_client
@@ -138,28 +140,24 @@ fn one_dot_zero_gates_default_to_full_length_soaks() {
}
#[test]
fn installers_skip_stale_latest_release_prebuilts() {
fn installers_resolve_the_repository_declared_release_without_stable_redirects() {
let install = include_str!("../install.sh");
assert!(install.contains("latest_release_is_stale"));
assert!(install.contains("current_source_version"));
assert!(install.contains("latest release $latest is older than source $current"));
assert!(install.contains("version_prerelease"));
assert!(install.contains("prerelease_less_than"));
assert!(
install.contains("if latest_release_is_stale; then"),
"unix installer must skip stale latest before downloading a prebuilt"
);
assert!(install.contains("release_source_ref"));
assert!(install.contains("printf 'v%s\\n' \"${current#v}\""));
assert!(install.contains("DOSH_BINARY_EXACT=1"));
assert!(!install.contains("/releases/latest"));
assert!(install.contains("resolved_source_ref=\"$(release_source_ref || true)\""));
assert!(install.contains("checkout -q --detach FETCH_HEAD"));
let ps1 = include_str!("../install.ps1");
assert!(ps1.contains("Latest-ReleaseIsStale"));
assert!(ps1.contains("Current-SourceVersion"));
assert!(ps1.contains("latest release $latestVersion is older than source $current"));
assert!(ps1.contains("Version-Prerelease"));
assert!(ps1.contains("Compare-Prerelease"));
assert!(
ps1.contains("if (Latest-ReleaseIsStale)"),
"windows installer must skip stale latest before downloading a prebuilt"
);
assert!(ps1.contains("Source-ReleaseRef"));
assert!(ps1.contains("\"v$($current.TrimStart('v'))\""));
assert!(ps1.contains("DOSH_BINARY_EXACT"));
assert!(!ps1.contains("/releases/latest"));
assert!(ps1.contains("$script:ResolvedSourceRef = Source-ReleaseRef"));
assert!(ps1.contains("checkout -q --detach FETCH_HEAD"));
}
#[test]
@@ -198,15 +196,16 @@ fn windows_installer_reuses_persistent_source_update_cache() {
assert!(ps1.contains("$full -eq $localAppData -or $full -eq $localAppDataDosh"));
assert!(ps1.contains("$sourceCache = Assert-SafeUpdateCache $UpdateCache"));
assert!(ps1.contains(
"$fetchArgs = @(\"-C\", $sourceCache, \"fetch\", \"--depth\", \"1\", \"origin\", \"main\")"
"$fetchArgs = @(\"-C\", $sourceCache, \"fetch\", \"--depth\", \"1\", \"origin\", $sourceRef)"
));
assert!(ps1.contains("$fetchArgs = @(\"-C\", $sourceCache, \"fetch\", \"-q\", \"--depth\", \"1\", \"origin\", \"main\")"));
assert!(ps1.contains("$fetchArgs = @(\"-C\", $sourceCache, \"fetch\", \"-q\", \"--depth\", \"1\", \"origin\", $sourceRef)"));
assert!(ps1.contains("git @fetchArgs"));
assert!(ps1.contains("git -C $sourceCache checkout -q -B main FETCH_HEAD"));
assert!(ps1.contains("git -C $sourceCache checkout -q --detach FETCH_HEAD"));
assert!(ps1.contains(
"$cloneArgs = @(\"clone\", \"--depth\", \"1\", \"--branch\", \"main\", $Repo, $sourceCache)"
"$cloneArgs = @(\"clone\", \"--depth\", \"1\", \"--branch\", $sourceRef, $Repo, $sourceCache)"
));
assert!(ps1.contains("$cloneArgs = @(\"clone\", \"-q\", \"--depth\", \"1\", \"--branch\", \"main\", $Repo, $sourceCache)"));
assert!(ps1.contains("$cloneArgs = @(\"clone\", \"-q\", \"--depth\", \"1\", \"--branch\", $sourceRef, $Repo, $sourceCache)"));
assert!(ps1.contains("git @cloneArgs | Out-Null"));
assert!(
!ps1.contains("git clone --depth 1 $Repo $tmp"),
@@ -218,8 +217,12 @@ fn windows_installer_reuses_persistent_source_update_cache() {
fn windows_quiet_source_updates_silence_git_like_unix() {
let install = include_str!("../install.sh");
let ps1 = include_str!("../install.ps1");
assert!(install.contains("git -C \"$update_cache\" fetch -q --depth 1 origin main"));
assert!(install.contains("git clone -q --depth 1 --branch main \"$repo\" \"$update_cache\""));
assert!(install.contains("git -C \"$update_cache\" fetch -q --depth 1 origin \"$source_ref\""));
assert!(
install.contains(
"git clone -q --depth 1 --branch \"$source_ref\" \"$repo\" \"$update_cache\""
)
);
assert!(ps1.contains("if ($Quiet)"));
assert!(ps1.contains("\"fetch\", \"-q\", \"--depth\""));
assert!(ps1.contains("\"clone\", \"-q\", \"--depth\""));