From 05543b1dc54e15b7aca942b611ee28b8d0b08f34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=90=93=F0=9D=90=9E=F0=9D=90=9A=F0=9D=90=A4?= =?UTF-8?q?=F0=9D=90=A8=F0=9D=90=B0=F0=9D=90=9A?= <27560638+Teakowa@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:41:23 +0800 Subject: [PATCH 01/11] fix(distribution): use R2 in Windows installer --- install.ps1 | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/install.ps1 b/install.ps1 index da05931..b147403 100644 --- a/install.ps1 +++ b/install.ps1 @@ -2,8 +2,7 @@ param( [string]$Version, [string]$InstallDir, - [string]$BaseUrl, - [string]$ApiUrl + [string]$BaseUrl ) $ErrorActionPreference = "Stop" @@ -20,19 +19,19 @@ function Resolve-Setting([string]$Value, [string]$EnvironmentName, [string]$Defa return $Default } -function Get-Version([string]$RequestedVersion, [string]$ReleaseApiUrl) { +function Get-Version([string]$RequestedVersion, [string]$ReleaseBaseUrl) { if ($RequestedVersion) { $resolved = $RequestedVersion.TrimStart("v") } else { + $latestVersionUrl = "$($ReleaseBaseUrl.TrimEnd('/'))/latest/version" try { - $release = Invoke-RestMethod -Uri $ReleaseApiUrl -Headers @{ "User-Agent" = "wright-installer" } - $resolved = ([string]$release.tag_name).TrimStart("v") + $resolved = ([string](Invoke-RestMethod -Uri $latestVersionUrl -Headers @{ "User-Agent" = "wright-installer" })).Trim().TrimStart("v") if (-not $resolved) { - Fail "latest release response from $ReleaseApiUrl did not contain a tag; pin a version with -Version" + Fail "latest version response from $latestVersionUrl was empty; pin a version with -Version" } } catch { - if ($_.Exception.Message -like "error: latest release response*") { throw } - Fail "could not resolve the latest release from $ReleaseApiUrl; pin a version with -Version" + if ($_.Exception.Message -like "error: latest version response*") { throw } + Fail "could not resolve the latest release from $latestVersionUrl; pin a version with -Version" } } if ($resolved -notmatch '^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$') { @@ -70,9 +69,9 @@ if ([Runtime.InteropServices.RuntimeInformation]::OSArchitecture -ne [Runtime.In Fail "unsupported CPU architecture; install.ps1 supports Windows x86_64 only" } -$BaseUrl = Resolve-Setting $BaseUrl "WRIGHT_INSTALL_BASE_URL" "https://github.com/wrightkit/wright/releases/download" -$ApiUrl = Resolve-Setting $ApiUrl "WRIGHT_API_URL" "https://api.github.com/repos/wrightkit/wright/releases/latest" -$Version = Get-Version $Version $ApiUrl +$BaseUrl = Resolve-Setting $BaseUrl "WRIGHT_INSTALL_BASE_URL" "https://releases.wrightkit.dev" +$VersionFromLatest = -not $Version +$Version = Get-Version $Version $BaseUrl if (-not $InstallDir) { $InstallRoot = if ($env:LOCALAPPDATA) { $env:LOCALAPPDATA } else { $env:USERPROFILE } if (-not $InstallRoot) { Fail "could not determine a user-writable install directory; pass -InstallDir" } @@ -80,7 +79,11 @@ if (-not $InstallDir) { } $ArchiveName = "wright-$Version-$Target.zip" -$ArchiveUrl = "$($BaseUrl.TrimEnd('/'))/v$Version/$ArchiveName" +if ($VersionFromLatest) { + $ArchiveUrl = "$($BaseUrl.TrimEnd('/'))/latest/$ArchiveName" +} else { + $ArchiveUrl = "$($BaseUrl.TrimEnd('/'))/releases/$Version/$ArchiveName" +} $ChecksumUrl = "${ArchiveUrl}.sha256" $TempRoot = Join-Path ([IO.Path]::GetTempPath()) ("wright-install-" + [Guid]::NewGuid().ToString("N")) $ExtractDir = Join-Path $TempRoot "extract" From 30b1e037aa9df65a51486c08dbcd7b8eeabfcedf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=90=93=F0=9D=90=9E=F0=9D=90=9A=F0=9D=90=A4?= =?UTF-8?q?=F0=9D=90=A8=F0=9D=90=B0=F0=9D=90=9A?= <27560638+Teakowa@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:42:05 +0800 Subject: [PATCH 02/11] test(distribution): model R2 paths for Windows installer --- scripts/test-install.ps1 | 49 ++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 27 deletions(-) diff --git a/scripts/test-install.ps1 b/scripts/test-install.ps1 index 5034f11..90fe5fc 100644 --- a/scripts/test-install.ps1 +++ b/scripts/test-install.ps1 @@ -13,9 +13,10 @@ function Fail([string]$Message) { } try { - $Release = Join-Path $Work "v$Version" - $Payload = Join-Path $Release "wright-$Version-$Target" - New-Item -ItemType Directory -Path $Payload -Force | Out-Null + $VersionedRelease = Join-Path $Work "releases\$Version" + $LatestRelease = Join-Path $Work "latest" + $Payload = Join-Path $Work "payload\wright-$Version-$Target" + New-Item -ItemType Directory -Path $VersionedRelease, $LatestRelease, $Payload -Force | Out-Null foreach ($Name in @("wright.exe", "wright-lsp.exe")) { $Source = Join-Path $Root "target\debug\$Name" if (-not (Test-Path -LiteralPath $Source -PathType Leaf)) { @@ -24,37 +25,30 @@ try { Copy-Item -LiteralPath $Source -Destination (Join-Path $Payload $Name) } $ArchiveName = "wright-$Version-$Target.zip" - $Archive = Join-Path $Release $ArchiveName + $Archive = Join-Path $VersionedRelease $ArchiveName Compress-Archive -LiteralPath $Payload -DestinationPath $Archive -Force $Hash = (Get-FileHash -LiteralPath $Archive -Algorithm SHA256).Hash.ToLowerInvariant() "$Hash $ArchiveName" | Set-Content -LiteralPath "${Archive}.sha256" -NoNewline -Encoding ASCII + Copy-Item -LiteralPath $Archive -Destination (Join-Path $LatestRelease $ArchiveName) + Copy-Item -LiteralPath "${Archive}.sha256" -Destination (Join-Path $LatestRelease "$ArchiveName.sha256") + $Version | Set-Content -LiteralPath (Join-Path $LatestRelease "version") -NoNewline -Encoding ASCII - $ApiDirectory = Join-Path $Work "repos\wrightkit\wright\releases" - New-Item -ItemType Directory -Path $ApiDirectory -Force | Out-Null - '{"tag_name":"v' + $Version + '","draft":false,"prerelease":false}' | - Set-Content -LiteralPath (Join-Path $ApiDirectory "latest") -NoNewline -Encoding ASCII $ServerCode = @' import http.server import os import sys -class Handler(http.server.SimpleHTTPRequestHandler): - def guess_type(self, path): - if path.endswith("/latest"): - return "application/json" - return super().guess_type(path) - os.chdir(sys.argv[2]) -http.server.ThreadingHTTPServer(("127.0.0.1", int(sys.argv[1])), Handler).serve_forever() +http.server.ThreadingHTTPServer(("127.0.0.1", int(sys.argv[1])), http.server.SimpleHTTPRequestHandler).serve_forever() '@ $ServerScript = Join-Path $Work "server.py" $ServerCode | Set-Content -LiteralPath $ServerScript -NoNewline -Encoding ASCII $Server = Start-Process -FilePath "python" -ArgumentList @($ServerScript, $Port, $Work) -PassThru -WindowStyle Hidden $BaseUrl = "http://127.0.0.1:$Port" - $ApiUrl = "$BaseUrl/repos/wrightkit/wright/releases/latest" + $LatestVersionUrl = "$BaseUrl/latest/version" for ($Attempt = 0; $Attempt -lt 30; $Attempt++) { try { - Invoke-RestMethod -Uri $ApiUrl | Out-Null + Invoke-RestMethod -Uri $LatestVersionUrl | Out-Null break } catch { if ($Attempt -eq 29) { Fail "local release server did not become ready" } @@ -65,7 +59,7 @@ http.server.ThreadingHTTPServer(("127.0.0.1", int(sys.argv[1])), Handler).serve_ $UnknownVersion = "0.0.0" $UnknownDir = Join-Path $Work "unknown" try { - & $Installer -Version $UnknownVersion -InstallDir $UnknownDir -BaseUrl $BaseUrl -ApiUrl $ApiUrl + & $Installer -Version $UnknownVersion -InstallDir $UnknownDir -BaseUrl $BaseUrl Fail "unknown exact version was accepted or ignored" } catch { if ($_.Exception.Message -notmatch "failed to download") { throw } @@ -76,7 +70,7 @@ http.server.ThreadingHTTPServer(("127.0.0.1", int(sys.argv[1])), Handler).serve_ Write-Host "PASS: exact version selection" $PinnedDir = Join-Path $Work "pinned" - & $Installer -Version $Version -InstallDir $PinnedDir -BaseUrl $BaseUrl -ApiUrl $ApiUrl + & $Installer -Version $Version -InstallDir $PinnedDir -BaseUrl $BaseUrl if (-not (Test-Path -LiteralPath (Join-Path $PinnedDir "wright.exe")) -or -not (Test-Path -LiteralPath (Join-Path $PinnedDir "wright-lsp.exe"))) { Fail "pinned install did not install both executables" @@ -88,17 +82,10 @@ http.server.ThreadingHTTPServer(("127.0.0.1", int(sys.argv[1])), Handler).serve_ if ($LASTEXITCODE -ne 0) { Fail "native post-install smoke failed" } Write-Host "PASS: pinned install and native smoke check" - $LatestDir = Join-Path $Work "latest" - & $Installer -InstallDir $LatestDir -BaseUrl $BaseUrl -ApiUrl $ApiUrl - if (-not (Test-Path -LiteralPath (Join-Path $LatestDir "wright.exe"))) { - Fail "latest-release install did not install wright.exe" - } - Write-Host "PASS: latest-release resolution" - "$(('0' * 64) -join '') $ArchiveName" | Set-Content -LiteralPath "${Archive}.sha256" -NoNewline -Encoding ASCII $CorruptDir = Join-Path $Work "corrupt" try { - & $Installer -Version $Version -InstallDir $CorruptDir -BaseUrl $BaseUrl -ApiUrl $ApiUrl + & $Installer -Version $Version -InstallDir $CorruptDir -BaseUrl $BaseUrl Fail "checksum mismatch was accepted" } catch { if ($_.Exception.Message -notmatch "checksum verification failed") { throw } @@ -107,6 +94,14 @@ http.server.ThreadingHTTPServer(("127.0.0.1", int(sys.argv[1])), Handler).serve_ Fail "checksum failure left a partial installation" } Write-Host "PASS: checksum mismatch is rejected before installation" + + Remove-Item -LiteralPath $VersionedRelease -Recurse -Force + $LatestDir = Join-Path $Work "latest-install" + & $Installer -InstallDir $LatestDir -BaseUrl $BaseUrl + if (-not (Test-Path -LiteralPath (Join-Path $LatestDir "wright.exe"))) { + Fail "latest-release install did not install wright.exe" + } + Write-Host "PASS: latest-release R2 path resolution" } finally { if ($Server) { Stop-Process -Id $Server.Id -Force -ErrorAction SilentlyContinue } if (Test-Path -LiteralPath $Work) { Remove-Item -LiteralPath $Work -Recurse -Force -ErrorAction SilentlyContinue } From dcdd66962c63f81f37741dc43bbb13587eae1278 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=90=93=F0=9D=90=9E=F0=9D=90=9A=F0=9D=90=A4?= =?UTF-8?q?=F0=9D=90=A8=F0=9D=90=B0=F0=9D=90=9A?= <27560638+Teakowa@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:42:45 +0800 Subject: [PATCH 03/11] fix(distribution): read R2 latest version as plain text --- install.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install.ps1 b/install.ps1 index b147403..6c84852 100644 --- a/install.ps1 +++ b/install.ps1 @@ -25,7 +25,7 @@ function Get-Version([string]$RequestedVersion, [string]$ReleaseBaseUrl) { } else { $latestVersionUrl = "$($ReleaseBaseUrl.TrimEnd('/'))/latest/version" try { - $resolved = ([string](Invoke-RestMethod -Uri $latestVersionUrl -Headers @{ "User-Agent" = "wright-installer" })).Trim().TrimStart("v") + $resolved = ([string](Invoke-WebRequest -Uri $latestVersionUrl -UseBasicParsing).Content).Trim().TrimStart("v") if (-not $resolved) { Fail "latest version response from $latestVersionUrl was empty; pin a version with -Version" } From 3eeae34cf3574b7366d08f0ed721f72100ecfa71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=90=93=F0=9D=90=9E=F0=9D=90=9A=F0=9D=90=A4?= =?UTF-8?q?=F0=9D=90=A8=F0=9D=90=B0=F0=9D=90=9A?= <27560638+Teakowa@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:43:00 +0800 Subject: [PATCH 04/11] test(distribution): treat latest pointer as plain text --- scripts/test-install.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/test-install.ps1 b/scripts/test-install.ps1 index 90fe5fc..bd7272f 100644 --- a/scripts/test-install.ps1 +++ b/scripts/test-install.ps1 @@ -48,7 +48,7 @@ http.server.ThreadingHTTPServer(("127.0.0.1", int(sys.argv[1])), http.server.Sim $LatestVersionUrl = "$BaseUrl/latest/version" for ($Attempt = 0; $Attempt -lt 30; $Attempt++) { try { - Invoke-RestMethod -Uri $LatestVersionUrl | Out-Null + Invoke-WebRequest -Uri $LatestVersionUrl -UseBasicParsing | Out-Null break } catch { if ($Attempt -eq 29) { Fail "local release server did not become ready" } From e9fb2f4dcf96dc42254dd5fa64a5ca4301c3da30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=90=93=F0=9D=90=9E=F0=9D=90=9A=F0=9D=90=A4?= =?UTF-8?q?=F0=9D=90=A8=F0=9D=90=B0=F0=9D=90=9A?= <27560638+Teakowa@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:25:05 +0800 Subject: [PATCH 05/11] fix(distribution): decode R2 latest version text on Windows --- install.ps1 | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/install.ps1 b/install.ps1 index 6c84852..c3bc720 100644 --- a/install.ps1 +++ b/install.ps1 @@ -25,7 +25,13 @@ function Get-Version([string]$RequestedVersion, [string]$ReleaseBaseUrl) { } else { $latestVersionUrl = "$($ReleaseBaseUrl.TrimEnd('/'))/latest/version" try { - $resolved = ([string](Invoke-WebRequest -Uri $latestVersionUrl -UseBasicParsing).Content).Trim().TrimStart("v") + $response = Invoke-WebRequest -Uri $latestVersionUrl -UseBasicParsing + $content = if ($response.Content -is [byte[]]) { + [Text.Encoding]::UTF8.GetString($response.Content) + } else { + [string]$response.Content + } + $resolved = $content.Trim().TrimStart("v") if (-not $resolved) { Fail "latest version response from $latestVersionUrl was empty; pin a version with -Version" } From f10c5c870d0cba391236d2e88c9c9be7672a9978 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Wed, 9 Sep 2026 04:31:56 +0800 Subject: [PATCH 06/11] fix(distribution): require HTTP/2 on Windows downloads Refs #284 --- install.ps1 | 14 +++++++------- scripts/test-install.ps1 | 6 ++++++ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/install.ps1 b/install.ps1 index c3bc720..4a01d82 100644 --- a/install.ps1 +++ b/install.ps1 @@ -25,11 +25,9 @@ function Get-Version([string]$RequestedVersion, [string]$ReleaseBaseUrl) { } else { $latestVersionUrl = "$($ReleaseBaseUrl.TrimEnd('/'))/latest/version" try { - $response = Invoke-WebRequest -Uri $latestVersionUrl -UseBasicParsing - $content = if ($response.Content -is [byte[]]) { - [Text.Encoding]::UTF8.GetString($response.Content) - } else { - [string]$response.Content + $content = & curl.exe --fail --location --silent --show-error --http2 $latestVersionUrl + if ($LASTEXITCODE -ne 0) { + Fail "could not resolve the latest release from $latestVersionUrl; pin a version with -Version" } $resolved = $content.Trim().TrimStart("v") if (-not $resolved) { @@ -101,8 +99,10 @@ try { $ChecksumPath = "$ArchivePath.sha256" Write-Host "==> downloading $ArchiveUrl" try { - Invoke-WebRequest -Uri $ArchiveUrl -OutFile $ArchivePath -UseBasicParsing - Invoke-WebRequest -Uri $ChecksumUrl -OutFile $ChecksumPath -UseBasicParsing + & curl.exe --fail --location --silent --show-error --http2 --output $ArchivePath $ArchiveUrl + if ($LASTEXITCODE -ne 0) { throw "archive download failed" } + & curl.exe --fail --location --silent --show-error --http2 --output $ChecksumPath $ChecksumUrl + if ($LASTEXITCODE -ne 0) { throw "checksum download failed" } } catch { Fail "failed to download the release archive or checksum for v$Version from $BaseUrl" } diff --git a/scripts/test-install.ps1 b/scripts/test-install.ps1 index bd7272f..57fcdd9 100644 --- a/scripts/test-install.ps1 +++ b/scripts/test-install.ps1 @@ -13,6 +13,12 @@ function Fail([string]$Message) { } try { + $InstallerText = Get-Content -LiteralPath $Installer -Raw + $Http2RequestCount = [regex]::Matches($InstallerText, 'curl\.exe\s+--fail\s+--location\s+--silent\s+--show-error\s+--http2').Count + if ($Http2RequestCount -ne 3) { + Fail "installer must use curl.exe with HTTP/2 for latest, archive, and checksum requests" + } + $VersionedRelease = Join-Path $Work "releases\$Version" $LatestRelease = Join-Path $Work "latest" $Payload = Join-Path $Work "payload\wright-$Version-$Target" From ad7e1213abb1ea434ed49e417944a777f0a236db Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:32:28 +0800 Subject: [PATCH 07/11] fix(distribution): use BITS for Windows downloads Refs #284 --- install.ps1 | 21 +++++++++++++-------- scripts/test-install.ps1 | 10 +++++++--- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/install.ps1 b/install.ps1 index 4a01d82..2773b4a 100644 --- a/install.ps1 +++ b/install.ps1 @@ -19,16 +19,19 @@ function Resolve-Setting([string]$Value, [string]$EnvironmentName, [string]$Defa return $Default } +function Get-RemoteFile([string]$Uri, [string]$Destination) { + Start-BitsTransfer -Source $Uri -Destination $Destination -Priority Foreground -ErrorAction Stop +} + function Get-Version([string]$RequestedVersion, [string]$ReleaseBaseUrl) { if ($RequestedVersion) { $resolved = $RequestedVersion.TrimStart("v") } else { $latestVersionUrl = "$($ReleaseBaseUrl.TrimEnd('/'))/latest/version" + $latestVersionPath = Join-Path ([IO.Path]::GetTempPath()) ("wright-latest-" + [Guid]::NewGuid().ToString("N")) try { - $content = & curl.exe --fail --location --silent --show-error --http2 $latestVersionUrl - if ($LASTEXITCODE -ne 0) { - Fail "could not resolve the latest release from $latestVersionUrl; pin a version with -Version" - } + Get-RemoteFile $latestVersionUrl $latestVersionPath + $content = Get-Content -LiteralPath $latestVersionPath -Raw $resolved = $content.Trim().TrimStart("v") if (-not $resolved) { Fail "latest version response from $latestVersionUrl was empty; pin a version with -Version" @@ -36,6 +39,10 @@ function Get-Version([string]$RequestedVersion, [string]$ReleaseBaseUrl) { } catch { if ($_.Exception.Message -like "error: latest version response*") { throw } Fail "could not resolve the latest release from $latestVersionUrl; pin a version with -Version" + } finally { + if (Test-Path -LiteralPath $latestVersionPath) { + Remove-Item -LiteralPath $latestVersionPath -Force -ErrorAction SilentlyContinue + } } } if ($resolved -notmatch '^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$') { @@ -99,10 +106,8 @@ try { $ChecksumPath = "$ArchivePath.sha256" Write-Host "==> downloading $ArchiveUrl" try { - & curl.exe --fail --location --silent --show-error --http2 --output $ArchivePath $ArchiveUrl - if ($LASTEXITCODE -ne 0) { throw "archive download failed" } - & curl.exe --fail --location --silent --show-error --http2 --output $ChecksumPath $ChecksumUrl - if ($LASTEXITCODE -ne 0) { throw "checksum download failed" } + Get-RemoteFile $ArchiveUrl $ArchivePath + Get-RemoteFile $ChecksumUrl $ChecksumPath } catch { Fail "failed to download the release archive or checksum for v$Version from $BaseUrl" } diff --git a/scripts/test-install.ps1 b/scripts/test-install.ps1 index 57fcdd9..fd36951 100644 --- a/scripts/test-install.ps1 +++ b/scripts/test-install.ps1 @@ -14,9 +14,13 @@ function Fail([string]$Message) { try { $InstallerText = Get-Content -LiteralPath $Installer -Raw - $Http2RequestCount = [regex]::Matches($InstallerText, 'curl\.exe\s+--fail\s+--location\s+--silent\s+--show-error\s+--http2').Count - if ($Http2RequestCount -ne 3) { - Fail "installer must use curl.exe with HTTP/2 for latest, archive, and checksum requests" + if ($InstallerText -notmatch 'Start-BitsTransfer\s+-Source\s+\$Uri\s+-Destination\s+\$Destination\s+-Priority\s+Foreground\s+-ErrorAction\s+Stop' -or + $InstallerText -match 'curl\.exe|Invoke-(WebRequest|RestMethod)') { + Fail "installer must use Windows BITS for release downloads" + } + $DownloadCount = [regex]::Matches($InstallerText, '(?m)^\s*Get-RemoteFile\s+\$').Count + if ($DownloadCount -ne 3) { + Fail "installer must use BITS for latest, archive, and checksum requests" } $VersionedRelease = Join-Path $Work "releases\$Version" From 9f41bd44eec7fd4dfb52636016d422aeb625e199 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:48:45 +0800 Subject: [PATCH 08/11] test(distribution): smoke Windows installs from R2 Refs #284 --- .github/workflows/ci.yml | 4 ++++ scripts/test-install-r2.ps1 | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 scripts/test-install-r2.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8cab22..ae9b06e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -691,6 +691,10 @@ jobs: if: matrix.channel == 'install.ps1' shell: pwsh run: ./scripts/test-install.ps1 + - name: Run install.ps1 production R2 smoke + if: matrix.channel == 'install.ps1' + shell: pwsh + run: ./scripts/test-install-r2.ps1 - name: Run Homebrew channel validation if: matrix.channel == 'Homebrew' run: python scripts/test-distribution-homebrew.py diff --git a/scripts/test-install-r2.ps1 b/scripts/test-install-r2.ps1 new file mode 100644 index 0000000..2bd8fa7 --- /dev/null +++ b/scripts/test-install-r2.ps1 @@ -0,0 +1,35 @@ +$ErrorActionPreference = "Stop" +$Root = Split-Path -Parent $PSScriptRoot +$Installer = Join-Path $Root "install.ps1" +$BaseUrl = "https://releases.wrightkit.dev" +$Work = Join-Path ([IO.Path]::GetTempPath()) ("wright-install-r2-test-" + [Guid]::NewGuid().ToString("N")) +$InstallDir = Join-Path $Work "bin" + +function Fail([string]$Message) { + throw "FAIL: $Message" +} + +try { + & $Installer -InstallDir $InstallDir -BaseUrl $BaseUrl + $ExpectedExecutables = @("wright.exe", "wright-lsp.exe") + if ($ExpectedExecutables.Count -ne 2 -or + $ExpectedExecutables[0] -ne "wright.exe" -or + $ExpectedExecutables[1] -ne "wright-lsp.exe") { + Fail "production R2 smoke must cover wright.exe and wright-lsp.exe" + } + foreach ($Name in $ExpectedExecutables) { + $Executable = Join-Path $InstallDir $Name + if (-not (Test-Path -LiteralPath $Executable -PathType Leaf)) { + Fail "production R2 install did not install $Name" + } + & $Executable --version | Out-Null + if ($LASTEXITCODE -ne 0) { + Fail "production R2 install smoke failed for $Name" + } + } + Write-Host "PASS: production R2 install and native smoke check" +} finally { + if (Test-Path -LiteralPath $Work) { + Remove-Item -LiteralPath $Work -Recurse -Force -ErrorAction SilentlyContinue + } +} From 20361efe8837664ff1d01ea07b8f31011b1dde33 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:13:23 +0800 Subject: [PATCH 09/11] fix(distribution): require HTTP/2 for Windows downloads Refs #284 Refs #283 --- .github/workflows/release.yml | 10 ++- docs/release.md | 8 +-- install.ps1 | 124 +++++++++++++++++++++++++++++++--- install.sh | 8 +-- scripts/test-install.ps1 | 26 +++---- scripts/test-install.sh | 1 - 6 files changed, 137 insertions(+), 40 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 630a3ea..ca822b5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -467,12 +467,10 @@ jobs: actual_hash="$(sha256sum "${archive}" | awk 'NR == 1 { print $1 }')" test "${actual_hash}" = "${expected_hash}" name="$(basename "${archive}")" - for prefix in "releases/${VERSION}" latest; do - put_immutable "${archive}" "${prefix}/${name}" 'public, max-age=31536000, immutable' 'application/octet-stream' - put_immutable "${checksum}" "${prefix}/${name}.sha256" 'public, max-age=31536000, immutable' 'text/plain; charset=utf-8' - verify_public "${prefix}/${name}" "${archive}" 'max-age=31536000.*immutable' - verify_public "${prefix}/${name}.sha256" "${checksum}" 'max-age=31536000.*immutable' - done + put_immutable "${archive}" "releases/${VERSION}/${name}" 'public, max-age=31536000, immutable' 'application/octet-stream' + put_immutable "${checksum}" "releases/${VERSION}/${name}.sha256" 'public, max-age=31536000, immutable' 'text/plain; charset=utf-8' + verify_public "releases/${VERSION}/${name}" "${archive}" 'max-age=31536000.*immutable' + verify_public "releases/${VERSION}/${name}.sha256" "${checksum}" 'max-age=31536000.*immutable' done printf '%s\n' "${VERSION}" > "${GITHUB_WORKSPACE}/latest-version" diff --git a/docs/release.md b/docs/release.md index c749676..08b6147 100644 --- a/docs/release.md +++ b/docs/release.md @@ -121,10 +121,10 @@ https://releases.wrightkit.dev/releases//wright--/`. The release workflow publicly verifies every versioned +archive/checksum pair before writing that `latest/version` pointer, so the +installer cannot resolve a new version before its complete artifact set is +available. `latest/version` uses `Cache-Control: no-store`; all archive and checksum paths are version-named and use long-lived immutable caching. This avoids stale latest pointers without a separate Worker, API, or GitHub Releases API lookup. diff --git a/install.ps1 b/install.ps1 index 2773b4a..d249363 100644 --- a/install.ps1 +++ b/install.ps1 @@ -19,8 +19,119 @@ function Resolve-Setting([string]$Value, [string]$EnvironmentName, [string]$Defa return $Default } +if (-not ("Wright.Http2Downloader" -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; + +namespace Wright { + public static class Http2Downloader { + const uint WINHTTP_ACCESS_TYPE_DEFAULT_PROXY = 0; + const uint WINHTTP_FLAG_SECURE = 0x00800000; + const uint WINHTTP_OPTION_ENABLE_HTTP_PROTOCOL = 133; + const uint WINHTTP_OPTION_HTTP_PROTOCOL_USED = 134; + const uint WINHTTP_OPTION_HTTP_PROTOCOL_REQUIRED = 145; + const uint WINHTTP_PROTOCOL_FLAG_HTTP2 = 1; + const uint WINHTTP_QUERY_STATUS_CODE = 19; + const int ERROR_INSUFFICIENT_BUFFER = 122; + + [DllImport("winhttp.dll", CharSet = CharSet.Unicode, SetLastError = true)] + static extern IntPtr WinHttpOpen(string agent, uint accessType, IntPtr proxy, IntPtr bypass, uint flags); + [DllImport("winhttp.dll", CharSet = CharSet.Unicode, SetLastError = true)] + static extern IntPtr WinHttpConnect(IntPtr session, string server, ushort port, uint reserved); + [DllImport("winhttp.dll", CharSet = CharSet.Unicode, SetLastError = true)] + static extern IntPtr WinHttpOpenRequest(IntPtr connection, string verb, string objectName, string version, string referrer, IntPtr acceptTypes, uint flags); + [DllImport("winhttp.dll", SetLastError = true)] + static extern bool WinHttpSetOption(IntPtr handle, uint option, ref uint buffer, uint bufferLength); + [DllImport("winhttp.dll", SetLastError = true)] + static extern bool WinHttpQueryOption(IntPtr handle, uint option, out uint buffer, ref uint bufferLength); + [DllImport("winhttp.dll", CharSet = CharSet.Unicode, SetLastError = true)] + static extern bool WinHttpQueryHeaders(IntPtr request, uint infoLevel, string name, StringBuilder buffer, ref uint bufferLength, IntPtr index); + [DllImport("winhttp.dll", CharSet = CharSet.Unicode, SetLastError = true)] + static extern bool WinHttpSendRequest(IntPtr request, string headers, uint headersLength, IntPtr optional, uint optionalLength, uint totalLength, IntPtr context); + [DllImport("winhttp.dll", SetLastError = true)] + static extern bool WinHttpReceiveResponse(IntPtr request, IntPtr reserved); + [DllImport("winhttp.dll", SetLastError = true)] + static extern bool WinHttpQueryDataAvailable(IntPtr request, out uint available); + [DllImport("winhttp.dll", SetLastError = true)] + static extern bool WinHttpReadData(IntPtr request, [Out] byte[] buffer, uint bytesToRead, out uint bytesRead); + [DllImport("winhttp.dll", SetLastError = true)] + static extern bool WinHttpCloseHandle(IntPtr handle); + + static void Check(bool success, string operation) { + if (!success) throw new Win32Exception(Marshal.GetLastWin32Error(), operation); + } + + static uint StatusCode(IntPtr request) { + uint length = 0; + WinHttpQueryHeaders(request, WINHTTP_QUERY_STATUS_CODE, null, null, ref length, IntPtr.Zero); + if (Marshal.GetLastWin32Error() != ERROR_INSUFFICIENT_BUFFER) { + throw new Win32Exception(Marshal.GetLastWin32Error(), "WinHttpQueryHeaders"); + } + var value = new StringBuilder((int)(length / 2)); + Check(WinHttpQueryHeaders(request, WINHTTP_QUERY_STATUS_CODE, null, value, ref length, IntPtr.Zero), "WinHttpQueryHeaders"); + return UInt32.Parse(value.ToString()); + } + + public static void Download(string address, string destination) { + var uri = new Uri(address); + if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) { + throw new ArgumentException("only HTTP(S) URLs are supported", "address"); + } + IntPtr session = IntPtr.Zero; + IntPtr connection = IntPtr.Zero; + IntPtr request = IntPtr.Zero; + try { + session = WinHttpOpen("wright-installer", WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, IntPtr.Zero, IntPtr.Zero, 0); + if (session == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error(), "WinHttpOpen"); + uint http2 = WINHTTP_PROTOCOL_FLAG_HTTP2; + Check(WinHttpSetOption(session, WINHTTP_OPTION_ENABLE_HTTP_PROTOCOL, ref http2, sizeof(uint)), "WinHttpSetOption(HTTP/2)"); + connection = WinHttpConnect(session, uri.Host, (ushort)uri.Port, 0); + if (connection == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error(), "WinHttpConnect"); + uint flags = uri.Scheme == Uri.UriSchemeHttps ? WINHTTP_FLAG_SECURE : 0; + request = WinHttpOpenRequest(connection, "GET", uri.PathAndQuery, null, null, IntPtr.Zero, flags); + if (request == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error(), "WinHttpOpenRequest"); + if (uri.Scheme == Uri.UriSchemeHttps) { + Check(WinHttpSetOption(request, WINHTTP_OPTION_HTTP_PROTOCOL_REQUIRED, ref http2, sizeof(uint)), "WinHttpSetOption(require HTTP/2)"); + } + Check(WinHttpSendRequest(request, null, 0, IntPtr.Zero, 0, 0, IntPtr.Zero), "WinHttpSendRequest"); + Check(WinHttpReceiveResponse(request, IntPtr.Zero), "WinHttpReceiveResponse"); + if (uri.Scheme == Uri.UriSchemeHttps) { + uint length = sizeof(uint); + uint used; + Check(WinHttpQueryOption(request, WINHTTP_OPTION_HTTP_PROTOCOL_USED, out used, ref length), "WinHttpQueryOption(HTTP protocol)"); + if (used != WINHTTP_PROTOCOL_FLAG_HTTP2) throw new InvalidOperationException("WinHTTP did not negotiate HTTP/2"); + } + uint status = StatusCode(request); + if (status < 200 || status >= 300) throw new InvalidOperationException("HTTP status " + status); + using (var output = new FileStream(destination, FileMode.Create, FileAccess.Write, FileShare.None)) { + while (true) { + uint available; + Check(WinHttpQueryDataAvailable(request, out available), "WinHttpQueryDataAvailable"); + if (available == 0) break; + var buffer = new byte[(int)available]; + uint read; + Check(WinHttpReadData(request, buffer, available, out read), "WinHttpReadData"); + if (read == 0) throw new InvalidOperationException("WinHttpReadData returned no data"); + output.Write(buffer, 0, (int)read); + } + } + } finally { + if (request != IntPtr.Zero) WinHttpCloseHandle(request); + if (connection != IntPtr.Zero) WinHttpCloseHandle(connection); + if (session != IntPtr.Zero) WinHttpCloseHandle(session); + } + } + } +} +'@ +} + function Get-RemoteFile([string]$Uri, [string]$Destination) { - Start-BitsTransfer -Source $Uri -Destination $Destination -Priority Foreground -ErrorAction Stop + [Wright.Http2Downloader]::Download($Uri, $Destination) } function Get-Version([string]$RequestedVersion, [string]$ReleaseBaseUrl) { @@ -38,7 +149,7 @@ function Get-Version([string]$RequestedVersion, [string]$ReleaseBaseUrl) { } } catch { if ($_.Exception.Message -like "error: latest version response*") { throw } - Fail "could not resolve the latest release from $latestVersionUrl; pin a version with -Version" + Fail "could not resolve the latest release from $latestVersionUrl: $($_.Exception.Message); pin a version with -Version" } finally { if (Test-Path -LiteralPath $latestVersionPath) { Remove-Item -LiteralPath $latestVersionPath -Force -ErrorAction SilentlyContinue @@ -81,7 +192,6 @@ if ([Runtime.InteropServices.RuntimeInformation]::OSArchitecture -ne [Runtime.In } $BaseUrl = Resolve-Setting $BaseUrl "WRIGHT_INSTALL_BASE_URL" "https://releases.wrightkit.dev" -$VersionFromLatest = -not $Version $Version = Get-Version $Version $BaseUrl if (-not $InstallDir) { $InstallRoot = if ($env:LOCALAPPDATA) { $env:LOCALAPPDATA } else { $env:USERPROFILE } @@ -90,11 +200,7 @@ if (-not $InstallDir) { } $ArchiveName = "wright-$Version-$Target.zip" -if ($VersionFromLatest) { - $ArchiveUrl = "$($BaseUrl.TrimEnd('/'))/latest/$ArchiveName" -} else { - $ArchiveUrl = "$($BaseUrl.TrimEnd('/'))/releases/$Version/$ArchiveName" -} +$ArchiveUrl = "$($BaseUrl.TrimEnd('/'))/releases/$Version/$ArchiveName" $ChecksumUrl = "${ArchiveUrl}.sha256" $TempRoot = Join-Path ([IO.Path]::GetTempPath()) ("wright-install-" + [Guid]::NewGuid().ToString("N")) $ExtractDir = Join-Path $TempRoot "extract" @@ -109,7 +215,7 @@ try { Get-RemoteFile $ArchiveUrl $ArchivePath Get-RemoteFile $ChecksumUrl $ChecksumPath } catch { - Fail "failed to download the release archive or checksum for v$Version from $BaseUrl" + Fail "failed to download the release archive or checksum for v$Version from $BaseUrl: $($_.Exception.Message)" } Write-Host "==> verifying SHA-256 checksum" diff --git a/install.sh b/install.sh index 2a512ed..6a89183 100755 --- a/install.sh +++ b/install.sh @@ -24,7 +24,6 @@ WRIGHT_INSTALL_BASE_URL="${WRIGHT_INSTALL_BASE_URL:-https://releases.wrightkit.d VERSION="" INSTALL_DIR="" TMP_DIR="" -VERSION_FROM_LATEST=false usage() { sed -n '2,11p' "$0" | sed 's/^# \?//' @@ -114,7 +113,6 @@ fi if [[ -z "$VERSION" ]]; then echo "==> resolving latest stable release" - VERSION_FROM_LATEST=true VERSION="$(curl -fsSL "$WRIGHT_INSTALL_BASE_URL/latest/version" 2>/dev/null)" \ || fail "could not resolve the latest release from $WRIGHT_INSTALL_BASE_URL/latest/version; pin a version with --version" VERSION="$(printf '%s' "$VERSION" | tr -d '[:space:]')" @@ -125,11 +123,7 @@ if [[ -z "$VERSION" ]]; then fi ARCHIVE="wright-$VERSION-$TARGET.tar.gz" -if [[ "$VERSION_FROM_LATEST" == true ]]; then - ARCHIVE_URL="$WRIGHT_INSTALL_BASE_URL/latest/$ARCHIVE" -else - ARCHIVE_URL="$WRIGHT_INSTALL_BASE_URL/releases/$VERSION/$ARCHIVE" -fi +ARCHIVE_URL="$WRIGHT_INSTALL_BASE_URL/releases/$VERSION/$ARCHIVE" CHECKSUM_URL="$ARCHIVE_URL.sha256" EXPECTED_DIR="wright-$VERSION-$TARGET" diff --git a/scripts/test-install.ps1 b/scripts/test-install.ps1 index fd36951..93d2101 100644 --- a/scripts/test-install.ps1 +++ b/scripts/test-install.ps1 @@ -14,13 +14,15 @@ function Fail([string]$Message) { try { $InstallerText = Get-Content -LiteralPath $Installer -Raw - if ($InstallerText -notmatch 'Start-BitsTransfer\s+-Source\s+\$Uri\s+-Destination\s+\$Destination\s+-Priority\s+Foreground\s+-ErrorAction\s+Stop' -or - $InstallerText -match 'curl\.exe|Invoke-(WebRequest|RestMethod)') { - Fail "installer must use Windows BITS for release downloads" + if ($InstallerText -notmatch 'WinHttpSetOption\(session,\s*WINHTTP_OPTION_ENABLE_HTTP_PROTOCOL' -or + $InstallerText -notmatch 'WinHttpSetOption\(request,\s*WINHTTP_OPTION_HTTP_PROTOCOL_REQUIRED' -or + $InstallerText -notmatch 'WinHttpQueryOption\(request,\s*WINHTTP_OPTION_HTTP_PROTOCOL_USED' -or + $InstallerText -match 'Start-BitsTransfer|curl\.exe|Invoke-(WebRequest|RestMethod)') { + Fail "installer must require and verify WinHTTP HTTP/2 for HTTPS release downloads" } $DownloadCount = [regex]::Matches($InstallerText, '(?m)^\s*Get-RemoteFile\s+\$').Count if ($DownloadCount -ne 3) { - Fail "installer must use BITS for latest, archive, and checksum requests" + Fail "installer must use WinHTTP for latest, archive, and checksum requests" } $VersionedRelease = Join-Path $Work "releases\$Version" @@ -39,8 +41,6 @@ try { Compress-Archive -LiteralPath $Payload -DestinationPath $Archive -Force $Hash = (Get-FileHash -LiteralPath $Archive -Algorithm SHA256).Hash.ToLowerInvariant() "$Hash $ArchiveName" | Set-Content -LiteralPath "${Archive}.sha256" -NoNewline -Encoding ASCII - Copy-Item -LiteralPath $Archive -Destination (Join-Path $LatestRelease $ArchiveName) - Copy-Item -LiteralPath "${Archive}.sha256" -Destination (Join-Path $LatestRelease "$ArchiveName.sha256") $Version | Set-Content -LiteralPath (Join-Path $LatestRelease "version") -NoNewline -Encoding ASCII $ServerCode = @' @@ -92,6 +92,13 @@ http.server.ThreadingHTTPServer(("127.0.0.1", int(sys.argv[1])), http.server.Sim if ($LASTEXITCODE -ne 0) { Fail "native post-install smoke failed" } Write-Host "PASS: pinned install and native smoke check" + $LatestDir = Join-Path $Work "latest-install" + & $Installer -InstallDir $LatestDir -BaseUrl $BaseUrl + if (-not (Test-Path -LiteralPath (Join-Path $LatestDir "wright.exe"))) { + Fail "latest-release install did not install wright.exe" + } + Write-Host "PASS: latest-release versioned R2 path resolution" + "$(('0' * 64) -join '') $ArchiveName" | Set-Content -LiteralPath "${Archive}.sha256" -NoNewline -Encoding ASCII $CorruptDir = Join-Path $Work "corrupt" try { @@ -105,13 +112,6 @@ http.server.ThreadingHTTPServer(("127.0.0.1", int(sys.argv[1])), http.server.Sim } Write-Host "PASS: checksum mismatch is rejected before installation" - Remove-Item -LiteralPath $VersionedRelease -Recurse -Force - $LatestDir = Join-Path $Work "latest-install" - & $Installer -InstallDir $LatestDir -BaseUrl $BaseUrl - if (-not (Test-Path -LiteralPath (Join-Path $LatestDir "wright.exe"))) { - Fail "latest-release install did not install wright.exe" - } - Write-Host "PASS: latest-release R2 path resolution" } finally { if ($Server) { Stop-Process -Id $Server.Id -Force -ErrorAction SilentlyContinue } if (Test-Path -LiteralPath $Work) { Remove-Item -LiteralPath $Work -Recurse -Force -ErrorAction SilentlyContinue } diff --git a/scripts/test-install.sh b/scripts/test-install.sh index f9c9094..abfca99 100755 --- a/scripts/test-install.sh +++ b/scripts/test-install.sh @@ -67,7 +67,6 @@ for triple in x86_64-unknown-linux-gnu aarch64-apple-darwin x86_64-apple-darwin; make_archive "$WORK/mock" "$triple" done mkdir -p "$WORK/mock/latest" -cp "$WORK/mock/releases/$VERSION"/* "$WORK/mock/latest/" printf '%s\n' "$VERSION" > "$WORK/mock/latest/version" python3 -m http.server "$PORT" --directory "$WORK/mock" >/dev/null 2>&1 & From 71c81e0dea1dae4bb04a1c91eafafbf5e7577459 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:23:07 +0800 Subject: [PATCH 10/11] fix(distribution): namespace Wright R2 artifacts Refs #283 Refs #284 --- .github/workflows/release.yml | 12 ++++++------ docs/release.md | 10 +++++----- install.ps1 | 2 +- install.sh | 2 +- scripts/test-install-r2.ps1 | 3 +-- scripts/test-install.ps1 | 9 +++++---- scripts/test-install.sh | 28 +++++++++++++++++----------- 7 files changed, 36 insertions(+), 30 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ca822b5..d7ef6fd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -467,19 +467,19 @@ jobs: actual_hash="$(sha256sum "${archive}" | awk 'NR == 1 { print $1 }')" test "${actual_hash}" = "${expected_hash}" name="$(basename "${archive}")" - put_immutable "${archive}" "releases/${VERSION}/${name}" 'public, max-age=31536000, immutable' 'application/octet-stream' - put_immutable "${checksum}" "releases/${VERSION}/${name}.sha256" 'public, max-age=31536000, immutable' 'text/plain; charset=utf-8' - verify_public "releases/${VERSION}/${name}" "${archive}" 'max-age=31536000.*immutable' - verify_public "releases/${VERSION}/${name}.sha256" "${checksum}" 'max-age=31536000.*immutable' + put_immutable "${archive}" "wright/releases/${VERSION}/${name}" 'public, max-age=31536000, immutable' 'application/octet-stream' + put_immutable "${checksum}" "wright/releases/${VERSION}/${name}.sha256" 'public, max-age=31536000, immutable' 'text/plain; charset=utf-8' + verify_public "wright/releases/${VERSION}/${name}" "${archive}" 'max-age=31536000.*immutable' + verify_public "wright/releases/${VERSION}/${name}.sha256" "${checksum}" 'max-age=31536000.*immutable' done printf '%s\n' "${VERSION}" > "${GITHUB_WORKSPACE}/latest-version" aws s3api put-object \ --bucket "${R2_BUCKET}" \ - --key latest/version \ + --key wright/latest/version \ --body "${GITHUB_WORKSPACE}/latest-version" \ --cache-control 'no-store, max-age=0' \ --content-type 'text/plain; charset=utf-8' \ --endpoint-url "${R2_ENDPOINT}" >/dev/null - verify_public latest/version "${GITHUB_WORKSPACE}/latest-version" 'no-store' + verify_public wright/latest/version "${GITHUB_WORKSPACE}/latest-version" 'no-store' rm -f "${GITHUB_WORKSPACE}/latest-version" diff --git a/docs/release.md b/docs/release.md index 08b6147..eb89d68 100644 --- a/docs/release.md +++ b/docs/release.md @@ -115,11 +115,11 @@ already exposes. Pinned installs use immutable versioned objects: ```text -https://releases.wrightkit.dev/releases//wright--. -https://releases.wrightkit.dev/releases//wright--..sha256 +https://releases.wrightkit.dev/wright/releases//wright--. +https://releases.wrightkit.dev/wright/releases//wright--..sha256 ``` -Latest installs first read `https://releases.wrightkit.dev/latest/version`, +Latest installs first read `https://releases.wrightkit.dev/wright/latest/version`, then download the corresponding version-named archive and checksum from `/releases//`. The release workflow publicly verifies every versioned archive/checksum pair before writing that `latest/version` pointer, so the @@ -221,8 +221,8 @@ Configure these optional/required environment secrets: ## Supported installation channels All channels consume canonical released archives and none of them rebuild -Wright. `install.sh` consumes the R2 copies described above; the package -managers and Windows installer continue to consume GitHub Release archives. +Wright. `install.sh` and `install.ps1` consume the R2 copies described above; +the package managers continue to consume GitHub Release archives. Metadata lives under [`dist/`](dist/README.md), generated by `scripts/update-dist-manifests.py`, and is regenerated by the release PR maintenance step and again by the `package-manifests` job from the published per-target diff --git a/install.ps1 b/install.ps1 index d249363..bd63366 100644 --- a/install.ps1 +++ b/install.ps1 @@ -191,7 +191,7 @@ if ([Runtime.InteropServices.RuntimeInformation]::OSArchitecture -ne [Runtime.In Fail "unsupported CPU architecture; install.ps1 supports Windows x86_64 only" } -$BaseUrl = Resolve-Setting $BaseUrl "WRIGHT_INSTALL_BASE_URL" "https://releases.wrightkit.dev" +$BaseUrl = Resolve-Setting $BaseUrl "WRIGHT_INSTALL_BASE_URL" "https://releases.wrightkit.dev/wright" $Version = Get-Version $Version $BaseUrl if (-not $InstallDir) { $InstallRoot = if ($env:LOCALAPPDATA) { $env:LOCALAPPDATA } else { $env:USERPROFILE } diff --git a/install.sh b/install.sh index 6a89183..c0d52df 100755 --- a/install.sh +++ b/install.sh @@ -19,7 +19,7 @@ set -euo pipefail -WRIGHT_INSTALL_BASE_URL="${WRIGHT_INSTALL_BASE_URL:-https://releases.wrightkit.dev}" +WRIGHT_INSTALL_BASE_URL="${WRIGHT_INSTALL_BASE_URL:-https://releases.wrightkit.dev/wright}" VERSION="" INSTALL_DIR="" diff --git a/scripts/test-install-r2.ps1 b/scripts/test-install-r2.ps1 index 2bd8fa7..7188a3d 100644 --- a/scripts/test-install-r2.ps1 +++ b/scripts/test-install-r2.ps1 @@ -1,7 +1,6 @@ $ErrorActionPreference = "Stop" $Root = Split-Path -Parent $PSScriptRoot $Installer = Join-Path $Root "install.ps1" -$BaseUrl = "https://releases.wrightkit.dev" $Work = Join-Path ([IO.Path]::GetTempPath()) ("wright-install-r2-test-" + [Guid]::NewGuid().ToString("N")) $InstallDir = Join-Path $Work "bin" @@ -10,7 +9,7 @@ function Fail([string]$Message) { } try { - & $Installer -InstallDir $InstallDir -BaseUrl $BaseUrl + & $Installer -InstallDir $InstallDir $ExpectedExecutables = @("wright.exe", "wright-lsp.exe") if ($ExpectedExecutables.Count -ne 2 -or $ExpectedExecutables[0] -ne "wright.exe" -or diff --git a/scripts/test-install.ps1 b/scripts/test-install.ps1 index 93d2101..e773c71 100644 --- a/scripts/test-install.ps1 +++ b/scripts/test-install.ps1 @@ -14,7 +14,8 @@ function Fail([string]$Message) { try { $InstallerText = Get-Content -LiteralPath $Installer -Raw - if ($InstallerText -notmatch 'WinHttpSetOption\(session,\s*WINHTTP_OPTION_ENABLE_HTTP_PROTOCOL' -or + if ($InstallerText -notmatch 'https://releases\.wrightkit\.dev/wright' -or + $InstallerText -notmatch 'WinHttpSetOption\(session,\s*WINHTTP_OPTION_ENABLE_HTTP_PROTOCOL' -or $InstallerText -notmatch 'WinHttpSetOption\(request,\s*WINHTTP_OPTION_HTTP_PROTOCOL_REQUIRED' -or $InstallerText -notmatch 'WinHttpQueryOption\(request,\s*WINHTTP_OPTION_HTTP_PROTOCOL_USED' -or $InstallerText -match 'Start-BitsTransfer|curl\.exe|Invoke-(WebRequest|RestMethod)') { @@ -25,8 +26,8 @@ try { Fail "installer must use WinHTTP for latest, archive, and checksum requests" } - $VersionedRelease = Join-Path $Work "releases\$Version" - $LatestRelease = Join-Path $Work "latest" + $VersionedRelease = Join-Path $Work "wright\releases\$Version" + $LatestRelease = Join-Path $Work "wright\latest" $Payload = Join-Path $Work "payload\wright-$Version-$Target" New-Item -ItemType Directory -Path $VersionedRelease, $LatestRelease, $Payload -Force | Out-Null foreach ($Name in @("wright.exe", "wright-lsp.exe")) { @@ -54,7 +55,7 @@ http.server.ThreadingHTTPServer(("127.0.0.1", int(sys.argv[1])), http.server.Sim $ServerScript = Join-Path $Work "server.py" $ServerCode | Set-Content -LiteralPath $ServerScript -NoNewline -Encoding ASCII $Server = Start-Process -FilePath "python" -ArgumentList @($ServerScript, $Port, $Work) -PassThru -WindowStyle Hidden - $BaseUrl = "http://127.0.0.1:$Port" + $BaseUrl = "http://127.0.0.1:$Port/wright" $LatestVersionUrl = "$BaseUrl/latest/version" for ($Attempt = 0; $Attempt -lt 30; $Attempt++) { try { diff --git a/scripts/test-install.sh b/scripts/test-install.sh index abfca99..1c798a3 100755 --- a/scripts/test-install.sh +++ b/scripts/test-install.sh @@ -64,10 +64,10 @@ wait_for_server() { } for triple in x86_64-unknown-linux-gnu aarch64-apple-darwin x86_64-apple-darwin; do - make_archive "$WORK/mock" "$triple" + make_archive "$WORK/mock/wright" "$triple" done -mkdir -p "$WORK/mock/latest" -printf '%s\n' "$VERSION" > "$WORK/mock/latest/version" +mkdir -p "$WORK/mock/wright/latest" +printf '%s\n' "$VERSION" > "$WORK/mock/wright/latest/version" python3 -m http.server "$PORT" --directory "$WORK/mock" >/dev/null 2>&1 & SERVER_PID=$! @@ -87,8 +87,14 @@ report() { fi } +if grep -Fq 'https://releases.wrightkit.dev/wright' "$INSTALLER"; then + report "namespaced R2 default base" ok +else + report "namespaced R2 default base" fail +fi + run_install() { - WRIGHT_INSTALL_BASE_URL="$BASE_URL" "$INSTALLER" "$@" + WRIGHT_INSTALL_BASE_URL="$BASE_URL/wright" "$INSTALLER" "$@" } expect_success() { @@ -123,7 +129,7 @@ expect_success "latest release resolves without the GitHub API" "$WORK/d2" printf 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa %s\n' \ "wright-$VERSION-x86_64-unknown-linux-gnu.tar.gz" \ - > "$WORK/mock/releases/$VERSION/wright-$VERSION-x86_64-unknown-linux-gnu.tar.gz.sha256" + > "$WORK/mock/wright/releases/$VERSION/wright-$VERSION-x86_64-unknown-linux-gnu.tar.gz.sha256" if WRIGHT_INSTALL_OS=linux WRIGHT_INSTALL_ARCH=x86_64 \ run_install --dir "$WORK/d3" --version "$VERSION" >"$INSTALL_OUTPUT" 2>&1; then report "checksum mismatch is rejected before install" fail @@ -137,7 +143,7 @@ test ! -e "$WORK/d3/wright" \ || report "nothing installed after checksum failure" fail printf 'not-a-hash %s\n' "wright-$VERSION-x86_64-unknown-linux-gnu.tar.gz" \ - > "$WORK/mock/releases/$VERSION/wright-$VERSION-x86_64-unknown-linux-gnu.tar.gz.sha256" + > "$WORK/mock/wright/releases/$VERSION/wright-$VERSION-x86_64-unknown-linux-gnu.tar.gz.sha256" if WRIGHT_INSTALL_OS=linux WRIGHT_INSTALL_ARCH=x86_64 \ run_install --dir "$WORK/d3b" --version "$VERSION" >"$INSTALL_OUTPUT" 2>&1; then report "malformed checksum file is rejected" fail @@ -147,7 +153,7 @@ else || report "malformed checksum file is rejected" fail fi -(cd "$WORK/mock/releases/$VERSION" && shasum -a 256 "wright-$VERSION-x86_64-unknown-linux-gnu.tar.gz" \ +(cd "$WORK/mock/wright/releases/$VERSION" && shasum -a 256 "wright-$VERSION-x86_64-unknown-linux-gnu.tar.gz" \ > "wright-$VERSION-x86_64-unknown-linux-gnu.tar.gz.sha256") expect_failure "unknown version fails with an actionable error" "does release" "$WORK/d4" \ @@ -194,7 +200,7 @@ else fi mkdir -p "$WORK/home" -if HOME="$WORK/home" WRIGHT_INSTALL_BASE_URL="$BASE_URL" \ +if HOME="$WORK/home" WRIGHT_INSTALL_BASE_URL="$BASE_URL/wright" \ "$INSTALLER" --version "$VERSION" >/dev/null 2>&1 && test -x "$WORK/home/.local/bin/wright"; then report "default install directory (\$HOME/.local/bin)" ok @@ -203,8 +209,8 @@ else fi # Archive-layout regression: an archive missing wright-lsp must fail cleanly. -make_archive "$WORK/mock-broken" "x86_64-unknown-linux-gnu" -release="$WORK/mock-broken/releases/$VERSION" +make_archive "$WORK/mock-broken/wright" "x86_64-unknown-linux-gnu" +release="$WORK/mock-broken/wright/releases/$VERSION" rm -f "$release/wright-$VERSION-x86_64-unknown-linux-gnu.tar.gz" \ "$release/wright-$VERSION-x86_64-unknown-linux-gnu.tar.gz.sha256" dir="$release/wright-$VERSION-x86_64-unknown-linux-gnu" @@ -220,7 +226,7 @@ BROKEN_PID=$! BROKEN_BASE_URL="http://127.0.0.1:$((PORT + 1))" wait_for_server "$BROKEN_BASE_URL" if WRIGHT_INSTALL_OS=linux WRIGHT_INSTALL_ARCH=x86_64 \ - WRIGHT_INSTALL_BASE_URL="$BROKEN_BASE_URL" \ + WRIGHT_INSTALL_BASE_URL="$BROKEN_BASE_URL/wright" \ "$INSTALLER" --dir "$WORK/d9" --version "$VERSION" >"$INSTALL_OUTPUT" 2>&1; then report "archive missing wright-lsp fails cleanly" fail else From 5cce6e5f9f9450a7ebc7c21b9246ad69f72ab6c3 Mon Sep 17 00:00:00 2001 From: Teakowa <27560638+Teakowa@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:01:19 +0800 Subject: [PATCH 11/11] fix(distribution): parse Windows installer errors Refs #284 --- install.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/install.ps1 b/install.ps1 index bd63366..1cb0b73 100644 --- a/install.ps1 +++ b/install.ps1 @@ -149,7 +149,7 @@ function Get-Version([string]$RequestedVersion, [string]$ReleaseBaseUrl) { } } catch { if ($_.Exception.Message -like "error: latest version response*") { throw } - Fail "could not resolve the latest release from $latestVersionUrl: $($_.Exception.Message); pin a version with -Version" + Fail "could not resolve the latest release from ${latestVersionUrl}: $($_.Exception.Message); pin a version with -Version" } finally { if (Test-Path -LiteralPath $latestVersionPath) { Remove-Item -LiteralPath $latestVersionPath -Force -ErrorAction SilentlyContinue @@ -215,7 +215,7 @@ try { Get-RemoteFile $ArchiveUrl $ArchivePath Get-RemoteFile $ChecksumUrl $ChecksumPath } catch { - Fail "failed to download the release archive or checksum for v$Version from $BaseUrl: $($_.Exception.Message)" + Fail "failed to download the release archive or checksum for v$Version from ${BaseUrl}: $($_.Exception.Message)" } Write-Host "==> verifying SHA-256 checksum"