Install-GithubRelease.ps1
<#
.DESCRIPTION A cross-platform script to download, check the file hash, and make sure the binary is on your PATH. .SYNOPSIS Install a binary from a github release. .EXAMPLE Install-GithubRelease FluxCD Flux2 Install `Flux` from the https://github.com/FluxCD/Flux2 repository .EXAMPLE Install-GithubRelease earthly earthly Install `earthly` from the https://github.com/earthly/earthly repository .EXAMPLE Install-GithubRelease junegunn fzf Install `fzf` from the https://github.com/junegunn/fzf repository .EXAMPLE Install-GithubRelease BurntSushi ripgrep Install `rg` from the https://github.com/BurntSushi/ripgrep repository .EXAMPLE Install-GithubRelease opentofu opentofu Install `opentofu` from the https://github.com/opentofu/opentofu repository .EXAMPLE Install-GithubRelease twpayne chezmoi Install `chezmoi` from the https://github.com/twpayne/chezmoi repository .EXAMPLE Install-GithubRelease sharkdp bat Install-GithubRelease sharkdp fd Install `bat` and `fd` from their repositories .NOTES All these examples are (only) tested on Windows and WSL Ubuntu #> <#PSScriptInfo .VERSION 1.2.0 .GUID 802367c6-654a-450b-94db-87e1d52e020a .AUTHOR Joel Bennett .COMPANYNAME HuddledMasses.org .COPYRIGHT Copyright (c) 2019-2023, Joel Bennett .TAGS Install Github Releases Binaries Linux Windows .LICENSEURI https://github.com/Jaykul/BoxStarter-Boxes/blob/master/LICENSE .PROJECTURI https://github.com/Jaykul/BoxStarter-Boxes .RELEASENOTES - **1.2.0** Added support for .zip files on Linux Also for checksum files based on the name "SHA256SUMS" instead of "checksums" - **1.1.0** Added support for directly downloading binaries (.exe on Windows, or no extension) to support earthly/earthly - **1.0.0** Broke this out from my BoxStarter Boxes, so I could share it more easily. #> [CmdletBinding(SupportsShouldProcess)] param( # The user or organization that owns the repository [Parameter(Mandatory)] [Alias("User")] [string]$Org, # The name of the repository or project to download from [Parameter(Mandatory)] [string]$Repo, # The version (tag) of the release to download. Defaults to 'latest' which is always the latest release. [string]$Version = 'latest', # The location to install to. Defaults to $Env:LocalAppData\Programs on Windows, /usr/local/bin on Linux/MacOS [string]$BinDir ) function Get-OSPlatform { [CmdletBinding()] param( [switch]$Pattern ) $ri = [System.Runtime.InteropServices.RuntimeInformation] $platform = [System.Runtime.InteropServices.OSPlatform] # if $ri isn't defined, then we must be running in Powershell 5.1, which only works on Windows. $OS = if (-not $ri -or $ri::IsOSPlatform($platform::Windows)) { "windows" } elseif ($ri::IsOSPlatform($platform::Linux)) { "linux" } elseif ($ri::IsOSPlatform($platform::OSX)) { "darwin" } elseif ($ri::IsOSPlatform($platform::FreeBSD)) { "freebsd" } else { throw "unsupported platform" } if ($Pattern) { Write-Information $OS switch($OS) { "windows" { "windows|(?<!dar)win" } "linux" { "linux|unix" } "darwin" { "darwin|osx" } "freebsd" { "freebsd" } } } else { $OS } } function Get-OSArchitecture { [CmdletBinding()] param( [switch]$Pattern ) # PowerShell Core $Architecture = if (($arch = "$([Runtime.InteropServices.RuntimeInformation]::OSArchitecture)")) { $arch # Legacy Windows PowerShell } elseif ([Environment]::Is64BitOperatingSystem) { "X64"; } else { "X86"; } # Optionally, turn this into a regex pattern that usually works if ($Pattern) { Write-Information $arch switch($arch) { "Arm" { "arm(?!64)" } "Arm64" { "arm64" } "X86" { "x86|386" } "X64" { "amd64|x64|x86_64" } } } else { $arch } } function Get-GitHubRelease { [CmdletBinding()] param( [Parameter(Position=0)] [Alias("User")] [string]$Org, [Parameter(Position=1)] [string]$Repo, [Parameter(Position=2)] [string]$Tag = 'latest' ) Write-Debug "Checking GitHub for tag '$tag'" $result = if ($tag -eq 'latest') { Invoke-RestMethod "https://api.github.com/repos/$org/$repo/releases/$tag" -Headers @{Accept = 'application/json'} -Verbose:$false } else { Invoke-RestMethod "https://api.github.com/repos/$org/$repo/releases/tags/$tag" -Headers @{Accept = 'application/json'} -Verbose:$false } Write-Debug "Found tag '$($result.tag_name)' for $tag" $result } function Test-FileHash { [CmdletBinding()] param( [string]$Target, [string]$Checksum ) # If Checksum is a file, get the checksum from the file if (Test-Path $Checksum) { $basename = [Regex]::Escape([IO.Path]::GetFileName($Target)) Write-Debug "Checksum is a file, getting checksum for $basename from $checksum" $Checksum = (Select-String -LiteralPath $Checksum -Pattern $basename).Line -split "\s+|=" -notmatch $basename } $Actual = (Get-FileHash -LiteralPath $Target -Algorithm SHA256).Hash $Actual -eq $Checksum if ($Actual -ne $Checksum) { Write-Error "Checksum mismatch!`nExpected: $Checksum`nActual: $Actual" } else { Write-Verbose "Checksum matches $Actual" } } function Install-GitHubRelease { <# .SYNOPSIS Install a binary from a github release. .DESCRIPTION Cross-platform script to download, check file hash, and make sure the binary is on your PATH. #> [CmdletBinding(SupportsShouldProcess)] param( # The user or organization that owns the repository [Parameter(Mandatory)] [Alias("User")] [string]$Org, # The name of the repository or project to download from [Parameter(Mandatory)] [string]$Repo, # The version of the release to download. Defaults to 'latest' [string]$Version = 'latest', # The operating system (will be detected, if not specified) $OS = (Get-OSPlatform -Pattern), # The architecture (will be detected, if not specified) $Architecture = (Get-OSArchitecture -Pattern), # The location to install to. Defaults to $Env:LocalAppData\Programs on Windows, /usr/local/bin on Linux/MacOS [string]$BinDir = $(if ($OS -notmatch "windows") { '/usr/local/bin' } elseif ($Env:LocalAppData) { "$Env:LocalAppData\Programs\Tools" } else { "$HOME/.tools" }) ) # A list of extensions in order of preference $extension = ".zip", ".tgz", ".tar.gz", ".exe" $release = Get-GitHubRelease @PSBoundParameters Write-Verbose "found release $($release.tag_name) for $org/$repo" $assets = $release.assets.where{ $_.name -match $OS -and $_.name -match $Architecture } | Select-Object *, @{ Name = "Extension"; Expr = { $_.name -replace '^[^.]+$','' -replace ".*?((?:\.tar)?\.[^.]+$)",'$1' } } | Select-Object *, @{ Name = "Priority"; Expr = { if (($index = [array]::IndexOf($extension, $_.Extension)) -lt 0) { $index * -10 } else { $index } } } | Sort-Object Priority, Name if ($assets.Count -gt 1) { if ($asset = $assets.where({ $_.Extension -in $extension }, "First")) { Write-Warning "Found multiple assets for $OS/$Architecture`n $($assets| Format-Table name, Extension, b*url | Out-String)`nUsing $($asset.name)" # If it's not on windows, executables don't need an extesion } elseif ($os -notmatch "windows" -and ($asset = $assets.Where({!$_.Extension},"First",0))) { Write-Warning "Found multiple assets for $OS/$Architecture`n $($assets| Format-Table name, Extension, b*url | Out-String)`nUsing $($asset.name)" } else { throw "Found multiple assets for $OS/$Architecture`n $($assets| Format-Table name, Extension, b*url | Out-String)`nUnable to detect usable release." } } elseif ($assets.Count -eq 0) { throw "No asset found for $OS/$Architecture`n $($release.assets.name -join "`n")" } else { $asset = $assets[0] } # Make a folder to unpack in $tempdir = Join-Path ([IO.Path]::GetTempPath()) ([IO.Path]::GetRandomFileName()) New-Item -Type Directory -Path $tempdir | Out-Null Push-Location $tempdir $ProgressPreference = "SilentlyContinue" Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $asset.name -Verbose:$false # There might be a checksum file $checksum = $release.assets.where{ $_.name -match "checksum|sha256sums" }[0] if ($checksum.Count -gt 0) { Write-Verbose "Found checksum file $($checksum.name)" Invoke-WebRequest -Uri $checksum.browser_download_url -OutFile $checksum.name -Verbose:$false if (!(Test-FileHash -Target $asset.name -Checksum $checksum.name)) { throw "Checksum mismatch for $($asset.name)" } } else { Write-Warning "No checksum file found for $($asset.name)" } # If it's an archive, expand it if ($asset.Extension -and $asset.Extension -ne ".exe") { $File = Get-Item $asset.name New-Item -Type Directory -Path $Repo | Convert-Path -OutVariable PackagePath | Set-Location Write-Verbose "Extracting $File to $PackagePath" if ($asset.Extension -eq ".zip") { Microsoft.PowerShell.Archive\Expand-Archive $File.FullName } else { if ($VerbosePreference -eq "Continue") { tar -xzvf $File.FullName } else { tar -xzf $File.FullName } } Set-Location $tempdir } else { Remove-Item $checksum.name $PackagePath = $tempdir } $Filter = @{ } if ($OS -match "windows") { $Filter.Include = @($ENV:PATHEXT -replace '\.', '*.' -split ';') + '*.exe' } if (!(Test-Path $BinDir)) { # First time use of $BinDir if ($Force -or $PSCmdlet.ShouldContinue("Create $BinDir and add to Path?", "$BinDir does not exist")) { New-Item -Type Directory -Path $BinDir | Out-Null if ($Env:PATH -split [IO.Path]::PathSeparator -notcontains $BinDir) { $Env:PATH += [IO.Path]::PathSeparator + $BinDir # If it's *not* Windows, $BinDir should be /usr/local/bin or something already in your PATH # Make the change permanent if ($OS -match "windows") { $PATH = [Environment]::GetEnvironmentVariable("PATH", [EnvironmentVariableTarget]::User) $PATH += [IO.Path]::PathSeparator + $BinDir [Environment]::SetEnvironmentVariable("PATH", $PATH, [EnvironmentVariableTarget]::User) } } } else { throw "Cannot install $Repo to $BinDir" } } Write-Verbose "Moving files from $PackagePath" foreach ($File in Get-ChildItem $PackagePath -File -Recurse @Filter) { # Some teams (e.g. earthly/earthly), name the actual binary with the platform name, which is annoying if ($File.BaseName -match $OS -and $File.BaseName -match $Architecture ) { # $File = Rename-Item $File.FullName -NewName "$Repo$($_.Extension)" -PassThru if (!($NewName = ($File.BaseName -replace "[-_\.]*$OS" -replace "[-_\.]*$Architecture"))) { $NewName = $Repo } $NewName += $File.Extension Write-Warning "Renaming $File to $NewName" $File = Rename-Item $File.FullName -NewName $NewName -PassThru } # Some few teams include the docs with their package (e.g. opentofu) if ($File.BaseName -match "README|LICENSE|CHANGELOG" -or $File.Extension -in ".md", ".rst", ".txt", ".asc", ".doc" ) { Write-Verbose "Skipping doc $File" continue } Write-Verbose "Moving $File to $BinDir" if ($OS -notmatch "windows" -and (Get-Item $BinDir).Attributes -eq "ReadOnly,Directory") { sudo mv -f $File.FullName $BinDir sudo chmod +x "$BinDir/$($File.Name)" } else { if (Test-Path $BinDir/$($File.Name)) { Remove-Item $BinDir/$($File.Name) -Recurse -Force } Move-Item $File.FullName -Destination $BinDir -Force -ErrorAction Stop if ($OS -notmatch "windows") { chmod +x "$BinDir/$($File.Name)" } } } Pop-Location Remove-Item $tempdir -Recurse } Install-GitHubRelease @PSBoundParameters |