# Reprobuild installer — served from https://get.reprobuild.com/pwsh # Source: metacraft-labs/reprobuild scripts/install/repro-install.ps1 @ cbce772 (assembled 2026-10-16T13:25:13Z) # <# .SYNOPSIS Reprobuild installer — the PowerShell half of M3. .DESCRIPTION irm https://get.reprobuild.com/pwsh | iex .\repro-install.ps1 -Method scoop .\repro-install.ps1 -Uninstall detect -> verify -> register the native repo (a Scoop bucket) -> let SCOOP install. After that `scoop update reprobuild` moves the user to newer releases, which is the Windows equivalent of `apt upgrade` and the reason a bucket is registered instead of a zip being unpacked. ## What verification means on this platform, precisely Scoop manifests carry a `hash` for every artifact and Scoop refuses a download whose hash does not match. That is the package manager's own check, it happens on install AND on every update, and it is rooted in the manifest — so the thing that has to be trusted is the BUCKET. The bucket is a git repository, and the trust root is therefore the bucket URL plus (for the repo-less path) an OpenPGP signature over the release manifest. There is no Windows equivalent of apt's signed InRelease, so this installer does NOT claim one: * -Method scoop : Scoop verifies artifact hashes against the manifest. Bucket authenticity rests on HTTPS to the bucket host. * -Method tarball : the full M2 signature chain, via repro-verify-release.sh, before anything is unpacked. Requires gpg/gpgv on PATH and FAILS CLOSED without it. Stating that asymmetry is deliberate. Claiming the Scoop path carries an OpenPGP guarantee it does not would be worse than the gap itself. ## Idempotence Every write is a fixed location and a replacement: one bucket named `metacraft` (shared by every Metacraft product), one Scoop app. Adding a bucket that is already present is detected and skipped rather than retried, so a second run does not produce a duplicate bucket or a second app. #> [CmdletBinding()] param( # auto | scoop | tarball [string]$Method = 'auto', [string]$Version = '', [switch]$Uninstall, # THE configurable base URL. Unset, the production subdomains under # -Domain are used; set, every fetch becomes a path under this one base: # /scoop the Scoop bucket (a git repo) # /downloads release archives + SHA256SUMS{,.asc} # /keys the trust anchor # so the whole installer retargets at a local server with one switch. # The gate does exactly that; nothing here hardcodes a hostname that # only resolves against the real (still-undelegated) zone. [string]$BaseUrl = $env:REPRO_BASE_URL, [string]$Domain = $(if ($env:REPRO_REPO_DOMAIN) { $env:REPRO_REPO_DOMAIN } else { 'metacraft-labs.com' }), [string]$BucketUrl = $env:REPRO_BUCKET_URL, [string]$Prefix = '', [switch]$DryRun, # Accepted and ignored: this installer never prompts (a prompt in an # `irm | iex` pipeline has no console to read from). [switch]$Yes ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest $Product = 'Reprobuild' $AppName = if ($env:REPRO_PKG_NAME) { $env:REPRO_PKG_NAME } else { 'reprobuild' } # The organisation's bucket, shared by every Metacraft product, as the apt # and RPM repositories are (metacraft-specs # infrastructure/package-distribution.md §3). $BucketName = if ($env:REPRO_BUCKET_NAME) { $env:REPRO_BUCKET_NAME } else { 'metacraft' } function Write-Log { param([string]$m) Write-Host "[$Product installer] $m" } function Write-Warn { param([string]$m) Write-Host "[$Product installer] WARNING: $m" -ForegroundColor Yellow } function Die { param([string]$m) Write-Host "[$Product installer] ERROR: $m" -ForegroundColor Red exit 1 } # --------------------------------------------------------------------- # URL construction — one knob # --------------------------------------------------------------------- if ($BaseUrl) { $b = $BaseUrl.TrimEnd('/') $ScoopBucketUrl = if ($BucketUrl) { $BucketUrl } else { "$b/scoop" } $DownloadsUrl = "$b/downloads" $KeysUrl = "$b/keys" } else { # The organisation's Scoop bucket: bucket/ on the default branch of # metacraft-desktop-packages, the repository that also publishes # deb./rpm.metacraft-labs.com. Every release refreshes its manifest. $ScoopBucketUrl = if ($BucketUrl) { $BucketUrl } else { 'https://github.com/metacraft-labs/metacraft-desktop-packages' } $DownloadsUrl = 'https://github.com/metacraft-labs/reprobuild/releases/download' $KeysUrl = "https://deb.$Domain/keys" } $KeyringFile = if ($env:REPRO_KEYRING_FILE) { $env:REPRO_KEYRING_FILE } else { 'metacraft-labs-archive-keyring.asc' } # Expected SHA-256 of the trust anchor: the organisation key, as the POSIX # installer pins it (see there). Set to an empty string to fail closed. $KeyringSha256 = if ($null -ne $env:REPRO_KEYRING_SHA256) { $env:REPRO_KEYRING_SHA256 } else { 'aa89db2215ce0b33e029b8302d4d94fac742b92d279f178437abe10d9b54c988' } # --------------------------------------------------------------------- # 1. detect # --------------------------------------------------------------------- function Get-ReproArch { # PROCESSOR_ARCHITECTURE is the process's view and reads AMD64 for a # 32-bit-emulated shell on ARM; the OS architecture is what decides # which asset to fetch. $a = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() switch ($a) { 'X64' { return 'x86_64' } 'Arm64' { return 'aarch64' } default { Die "unsupported OS architecture '$a'. See .github/release-platforms.json for the platforms reprobuild publishes (windows-aarch64 is deliberately absent: clingo has no native ARM64 build)." } } } function Test-Command { param([string]$n) return [bool](Get-Command $n -ErrorAction SilentlyContinue) } function Resolve-Method { if ($Method -ne 'auto') { return $Method } if (Test-Command 'scoop') { return 'scoop' } Write-Log 'scoop was not found; falling back to the signed archive method' return 'tarball' } function Invoke-Step { param([string]$What, [scriptblock]$Action) if ($DryRun) { Write-Log "DRY-RUN would: $What"; return $null } return & $Action } # --------------------------------------------------------------------- # 2. verify — the trust anchor (tarball path) # --------------------------------------------------------------------- function Get-FileSha256 { param([string]$p) return (Get-FileHash -Algorithm SHA256 -LiteralPath $p).Hash.ToLowerInvariant() } function Save-Url { param([string]$Url, [string]$Dest) Write-Log "fetch $Url" try { # -UseBasicParsing for Windows PowerShell 5.1, where the default # engine needs IE and throws on a server with no DOM. Invoke-WebRequest -Uri $Url -OutFile $Dest -UseBasicParsing -ErrorAction Stop | Out-Null } catch { Die "download failed: $Url ($($_.Exception.Message))" } if (-not (Test-Path -LiteralPath $Dest) -or (Get-Item -LiteralPath $Dest).Length -eq 0) { Die "downloaded an empty file from $Url" } } function Install-TrustAnchor { param([string]$WorkDir) $kr = Join-Path $WorkDir $KeyringFile if ($env:REPRO_KEYRING_LOCAL) { if (-not (Test-Path -LiteralPath $env:REPRO_KEYRING_LOCAL)) { Die "REPRO_KEYRING_LOCAL=$($env:REPRO_KEYRING_LOCAL) does not exist" } Copy-Item -LiteralPath $env:REPRO_KEYRING_LOCAL -Destination $kr -Force } else { Save-Url -Url "$KeysUrl/$KeyringFile" -Dest $kr } $got = Get-FileSha256 $kr if ($KeyringSha256) { if ($got -eq $KeyringSha256.ToLowerInvariant()) { Write-Log "trust anchor digest OK ($got)" } else { Die @" trust anchor digest MISMATCH expected $KeyringSha256 got $got Refusing to install a release key that is not the one this installer pins. "@ } } elseif ($env:REPRO_ALLOW_UNPINNED_KEYRING -eq '1') { Write-Warn "using an UNPINNED trust anchor (sha256=$got) because REPRO_ALLOW_UNPINNED_KEYRING=1. Not for production." } else { Die @" no trust anchor digest is pinned in this installer (REPRO_KEYRING_SHA256 is empty). reprobuild has no release key yet, so there is nothing to pin and this installer fails closed rather than trusting the keyring host blindly. See docs/release-signing.md. "@ } return $kr } # --------------------------------------------------------------------- # 3/4. Scoop: register the bucket, let scoop install # --------------------------------------------------------------------- function Get-ScoopBuckets { # `scoop bucket list` output shape has changed across Scoop versions, # so the buckets DIRECTORY is the source of truth rather than a parsed # table. A parser that silently matched nothing would make the # idempotence check below pass while adding a duplicate bucket. $root = if ($env:SCOOP) { $env:SCOOP } else { Join-Path $env:USERPROFILE 'scoop' } $bd = Join-Path $root 'buckets' if (-not (Test-Path -LiteralPath $bd)) { return @() } return @(Get-ChildItem -LiteralPath $bd -Directory -ErrorAction SilentlyContinue | ForEach-Object { $_.Name }) } function Register-ScoopBucket { if (-not (Test-Command 'scoop')) { Die @" scoop was not found on PATH. Install Scoop first (https://scoop.sh): Set-ExecutionPolicy -Scope CurrentUser RemoteSigned irm get.scoop.sh | iex or re-run this installer with -Method tarball. "@ } $existing = Get-ScoopBuckets if ($existing -contains $BucketName) { # IDEMPOTENCE: `scoop bucket add` on an existing name errors out, so # the second run must skip rather than retry-and-fail. Write-Log "scoop bucket '$BucketName' is already registered; leaving it as is" return } Write-Log "registering scoop bucket '$BucketName' -> $ScoopBucketUrl" Invoke-Step "scoop bucket add $BucketName $ScoopBucketUrl" { & scoop bucket add $BucketName $ScoopBucketUrl if ($LASTEXITCODE -ne 0) { Die "scoop bucket add failed (exit $LASTEXITCODE)" } } } function Install-WithScoop { $target = if ($Version) { "$BucketName/$AppName@$Version" } else { "$BucketName/$AppName" } Write-Log "installing $target with scoop" Invoke-Step "scoop install $target" { # Scoop's own action, Scoop's own hash verification, Scoop's own exit # code. Nothing here re-checks what Scoop already refuses. & scoop install $target if ($LASTEXITCODE -ne 0) { Die "scoop install $target failed (exit $LASTEXITCODE)" } } } function Uninstall-WithScoop { if (-not (Test-Command 'scoop')) { Write-Log 'scoop not present; nothing for scoop to remove'; return } $apps = @(& scoop list 6>$null | Out-String) if ($apps -match [regex]::Escape($AppName)) { Write-Log "removing $AppName with scoop" Invoke-Step "scoop uninstall $AppName" { & scoop uninstall $AppName if ($LASTEXITCODE -ne 0) { Write-Warn "scoop uninstall $AppName exited $LASTEXITCODE" } } } else { Write-Log "$AppName is not installed; nothing for scoop to remove" } if ((Get-ScoopBuckets) -contains $BucketName) { Write-Log "removing scoop bucket '$BucketName'" Invoke-Step "scoop bucket rm $BucketName" { & scoop bucket rm $BucketName if ($LASTEXITCODE -ne 0) { Write-Warn "scoop bucket rm $BucketName exited $LASTEXITCODE" } } } else { Write-Log "scoop bucket '$BucketName' is not registered; nothing to remove" } } # --------------------------------------------------------------------- # the repo-less fallback: signed archive, verified BEFORE extraction # --------------------------------------------------------------------- function Find-Verifier { if ($env:REPRO_VERIFY_SCRIPT) { if (-not (Test-Path -LiteralPath $env:REPRO_VERIFY_SCRIPT)) { Die "REPRO_VERIFY_SCRIPT=$($env:REPRO_VERIFY_SCRIPT) does not exist" } return $env:REPRO_VERIFY_SCRIPT } $here = Split-Path -Parent $PSCommandPath foreach ($c in @( (Join-Path $here '..\release-signing\repro-verify-release.sh'), (Join-Path $here 'repro-verify-release.sh'))) { if (Test-Path -LiteralPath $c) { return (Resolve-Path -LiteralPath $c).Path } } Die @" cannot locate repro-verify-release.sh. The archive fallback verifies signatures BEFORE extracting and will not run without the verifier. Set REPRO_VERIFY_SCRIPT. It will not proceed unverified. "@ } function Install-FromArchive { $arch = Get-ReproArch $ver = if ($Version) { $Version } elseif ($env:REPRO_VERSION) { $env:REPRO_VERSION } else { Die 'the archive fallback needs -Version. There is no "latest" to follow: resolving it from a directory listing is not a release policy.' } $asset = "reprobuild-$ver-windows-$arch.zip" $work = Join-Path ([System.IO.Path]::GetTempPath()) ("repro-install-" + [guid]::NewGuid().ToString('N')) New-Item -ItemType Directory -Path $work -Force | Out-Null try { Save-Url -Url "$DownloadsUrl/v$ver/$asset" -Dest (Join-Path $work $asset) Save-Url -Url "$DownloadsUrl/v$ver/SHA256SUMS" -Dest (Join-Path $work 'SHA256SUMS') Save-Url -Url "$DownloadsUrl/v$ver/SHA256SUMS.asc" -Dest (Join-Path $work 'SHA256SUMS.asc') Save-Url -Url "$DownloadsUrl/v$ver/$asset.asc" -Dest (Join-Path $work "$asset.asc") $anchor = Install-TrustAnchor -WorkDir $work # M2's verifier is a POSIX shell script. On Windows it needs a shell: # Git for Windows' sh.exe is the one release.yml already assumes is # present. No shell => no verification => no install. This is the # fail-closed rule from docs/release-signing.md, not an inconvenience # to route around. $sh = $null foreach ($c in @('sh', 'bash')) { if (Test-Command $c) { $sh = (Get-Command $c).Source; break } } if (-not $sh) { Die @" no POSIX shell (sh/bash) found, so repro-verify-release.sh cannot run. Install Git for Windows, or use -Method scoop (where Scoop verifies artifact hashes against its manifest). This installer will NOT extract an unverified archive. "@ } $verifier = Find-Verifier $vargs = @($verifier, '--keyring', $anchor, '--dir', $work, '--artifact', $asset) if ($env:REPRO_ALLOW_TEST_KEY -eq '1') { $vargs += '--allow-test-key' } Write-Log "verifying $asset with $verifier BEFORE extracting" if ($DryRun) { Write-Log 'DRY-RUN would verify and extract'; return } & $sh @vargs if ($LASTEXITCODE -ne 0) { Die 'signature verification failed; refusing to install' } Write-Log 'signature verification passed' $dest = if ($Prefix) { $Prefix } elseif ($env:REPRO_INSTALL_PREFIX) { $env:REPRO_INSTALL_PREFIX } else { Join-Path $env:LOCALAPPDATA 'Programs\Reprobuild' } $stage = Join-Path $work 'unpack' New-Item -ItemType Directory -Path $stage -Force | Out-Null # .NET's zip reader directly, not Expand-Archive: Windows PowerShell 5.1's # cmdlet writes a progress record per entry, which makes an archive of # thousands of files (the release carries its own sources) take minutes # instead of seconds. $stage is fresh, so nothing needs overwriting. Add-Type -AssemblyName System.IO.Compression.FileSystem [System.IO.Compression.ZipFile]::ExtractToDirectory((Join-Path $work $asset), $stage) $top = Join-Path $stage "reprobuild-$ver-windows-$arch" if (-not (Test-Path -LiteralPath $top)) { Die "archive did not contain the expected top-level directory reprobuild-$ver-windows-$arch" } New-Item -ItemType Directory -Path $dest -Force | Out-Null Copy-Item -Path (Join-Path $top '*') -Destination $dest -Recurse -Force # Record what was installed so -Uninstall removes exactly this. $manifestDir = Join-Path $dest '.reprobuild' New-Item -ItemType Directory -Path $manifestDir -Force | Out-Null Set-Content -LiteralPath (Join-Path $manifestDir 'version') -Value $ver -NoNewline Write-Log "archive install complete under $dest" Write-Warn 'an archive install does NOT receive native updates; re-run the installer or use -Method scoop.' } finally { Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue } } function Uninstall-Archive { $dest = if ($Prefix) { $Prefix } elseif ($env:REPRO_INSTALL_PREFIX) { $env:REPRO_INSTALL_PREFIX } else { Join-Path $env:LOCALAPPDATA 'Programs\Reprobuild' } if (Test-Path -LiteralPath (Join-Path $dest '.reprobuild')) { Write-Log "removing the archive install at $dest" if (-not $DryRun) { Remove-Item -LiteralPath $dest -Recurse -Force -ErrorAction SilentlyContinue } } else { Write-Log "no archive install recorded under $dest; nothing to remove" } } # --------------------------------------------------------------------- # main # --------------------------------------------------------------------- $resolved = Resolve-Method Write-Log "$Product installer: os=windows arch=$(Get-ReproArch) method=$resolved" if ($BaseUrl) { Write-Log "base URL override: $BaseUrl" } if ($Uninstall) { switch ($resolved) { 'scoop' { Uninstall-WithScoop; Uninstall-Archive } 'tarball' { Uninstall-Archive } default { Die "cannot uninstall with method=$resolved" } } Write-Log 'uninstall complete' exit 0 } switch ($resolved) { 'scoop' { Register-ScoopBucket Install-WithScoop Write-Log "$Product installed from the Scoop bucket." Write-Log "Future upgrades: 'scoop update $AppName' (not this script)." } 'tarball' { Install-FromArchive } default { Die "unsupported method: $resolved" } } $bin = Get-Command 'repro' -ErrorAction SilentlyContinue if (-not $DryRun -and $bin) { Write-Log "installed binary: $($bin.Source)" } exit 0