Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 9 additions & 9 deletions docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,16 +115,16 @@ already exposes.
Pinned installs use immutable versioned objects:

```text
https://releases.wrightkit.dev/releases/<version>/wright-<version>-<target-triple>.<ext>
https://releases.wrightkit.dev/releases/<version>/wright-<version>-<target-triple>.<ext>.sha256
https://releases.wrightkit.dev/wright/releases/<version>/wright-<version>-<target-triple>.<ext>
https://releases.wrightkit.dev/wright/releases/<version>/wright-<version>-<target-triple>.<ext>.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
`/latest/`. The release workflow uploads and publicly verifies every versioned
and latest 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
`/releases/<version>/`. 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.
Expand Down Expand Up @@ -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
Expand Down
150 changes: 135 additions & 15 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@
param(
[string]$Version,
[string]$InstallDir,
[string]$BaseUrl,
[string]$ApiUrl
[string]$BaseUrl
)

$ErrorActionPreference = "Stop"
Expand All @@ -20,19 +19,141 @@ function Resolve-Setting([string]$Value, [string]$EnvironmentName, [string]$Defa
return $Default
}

function Get-Version([string]$RequestedVersion, [string]$ReleaseApiUrl) {
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) {
[Wright.Http2Downloader]::Download($Uri, $Destination)
}

function Get-Version([string]$RequestedVersion, [string]$ReleaseBaseUrl) {
if ($RequestedVersion) {
$resolved = $RequestedVersion.TrimStart("v")
} else {
Comment thread
Teakowa marked this conversation as resolved.
$latestVersionUrl = "$($ReleaseBaseUrl.TrimEnd('/'))/latest/version"
$latestVersionPath = Join-Path ([IO.Path]::GetTempPath()) ("wright-latest-" + [Guid]::NewGuid().ToString("N"))
try {
$release = Invoke-RestMethod -Uri $ReleaseApiUrl -Headers @{ "User-Agent" = "wright-installer" }
$resolved = ([string]$release.tag_name).TrimStart("v")
Get-RemoteFile $latestVersionUrl $latestVersionPath
$content = Get-Content -LiteralPath $latestVersionPath -Raw
$resolved = $content.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}: $($_.Exception.Message); 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.-]+)?$') {
Expand Down Expand Up @@ -70,17 +191,16 @@ 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/wright"
$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" }
$InstallDir = Join-Path $InstallRoot "Programs\Wright\bin"
}

$ArchiveName = "wright-$Version-$Target.zip"
$ArchiveUrl = "$($BaseUrl.TrimEnd('/'))/v$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"
Expand All @@ -92,10 +212,10 @@ try {
$ChecksumPath = "$ArchivePath.sha256"
Write-Host "==> downloading $ArchiveUrl"
try {
Invoke-WebRequest -Uri $ArchiveUrl -OutFile $ArchivePath -UseBasicParsing
Invoke-WebRequest -Uri $ChecksumUrl -OutFile $ChecksumPath -UseBasicParsing
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"
Expand Down
10 changes: 2 additions & 8 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,11 @@

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=""
TMP_DIR=""
VERSION_FROM_LATEST=false

usage() {
sed -n '2,11p' "$0" | sed 's/^# \?//'
Expand Down Expand Up @@ -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:]')"
Expand All @@ -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"

Expand Down
34 changes: 34 additions & 0 deletions scripts/test-install-r2.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent $PSScriptRoot
$Installer = Join-Path $Root "install.ps1"
$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
$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
}
}
Loading