Eigenverft.Manifested.Drydock.GitHub.ps1
|
function Get-GitHubLatestRelease { <# .SYNOPSIS Gets latest GitHub release assets (Name, Version, DownloadUrl, Path) and downloads them into structured subfolders, with optional extraction for ZIP files. .DESCRIPTION Parses the provided GitHub repository URL (https://github.com/owner/repo), queries the GitHub API for the latest release, filters assets by allowlist (Whitelist, all patterns must match) and denylist (BlackList, any substring match), and downloads results into a target folder layout: - By default, downloads to the user's Downloads folder. - Per-asset subfolders are used by default; use -NoSubfolder to place the file directly under the chosen folder (or version folder). - Use -IncludeVersionFolder to insert a version folder (release tag) beneath the DownloadFolder. - With -Extract, .zip assets are downloaded to a temp location, extracted (overwrite on) into the target directory, and temp data is cleaned up. Non-zip assets are merely downloaded. Notes: - Uses WebClient for compatibility with Windows PowerShell 5/5.1. On PowerShell 7+, WebClient is also available. - Sets TLS 1.2 on older stacks when possible. - Minimal console logging via _Write-StandardMessage (INF/WRN/ERR/FTL gating). - Idempotent: re-running converges to the same on-disk state (files are overwritten, extracts overwrite). .PARAMETER RepoUrl Full URL to a GitHub repository, for example: https://github.com/owner/repo .PARAMETER Whitelist Wildcard patterns; only assets whose names match every provided pattern are included. If omitted, all assets are considered before BlackList filtering. .PARAMETER BlackList Substring patterns; assets whose names contain any provided substring (case-insensitive) are excluded. .PARAMETER DownloadFolder Root folder where assets will be placed. Defaults to the current user's Downloads folder if omitted. .PARAMETER NoSubfolder When present, disables the default per-asset subfolder creation. .PARAMETER IncludeVersionFolder When present, prepends the release tag as a version folder under DownloadFolder. .PARAMETER Extract When present, ZIP assets are extracted (overwrite) into the target directory after download; non-zip assets are downloaded as files. .OUTPUTS System.Object Each asset is emitted as a PSCustomObject with properties: - Name - Version - DownloadUrl - Path # file path for non-extracted items; target directory when extracted .EXAMPLE # Download all latest assets into per-asset folders under a version folder Get-GitHubLatestRelease -RepoUrl 'https://github.com/ggml-org/llama.cpp' -IncludeVersionFolder .EXAMPLE # Allowlist AVX2 builds only, exclude debug artifacts, extract ZIPs, do not create per-asset subfolders Get-GitHubLatestRelease -RepoUrl 'https://github.com/ggml-org/llama.cpp' -Whitelist '*avx2*' -BlackList 'debug' -NoSubfolder -Extract .EXAMPLE # Only x64 artifacts, exclude any 'beta' labeled assets, default layout (per-asset subfolders) Get-GitHubLatestRelease -RepoUrl 'https://github.com/owner/repo' -Whitelist '*x64*' -BlackList 'beta' .NOTES Requires internet access. Uses GitHub's public API and a default User-Agent. Handles TLS 1.2 enablement when possible. No external executables required. #> [CmdletBinding()] [Alias('gglr')] param( [Parameter(Mandatory=$true, Position=0)] [ValidateNotNullOrEmpty()] [string]$RepoUrl, [Parameter()] [string[]]$Whitelist, [Parameter()] [string[]]$BlackList, [Parameter()] [ValidateNotNullOrEmpty()] [string]$DownloadFolder, [Parameter()] [switch]$NoSubfolder, [Parameter()] [switch]$IncludeVersionFolder, [Parameter()] [switch]$Extract ) # ---- Inline helpers (local scope only) --------------------------------- function _Write-StandardMessage { [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] # This function is globally exempt from the GENERAL POWERSHELL REQUIREMENTS unless explicitly stated otherwise. [CmdletBinding()] param( [Parameter(Mandatory=$true)][AllowEmptyString()][string]$Message, [Parameter()][ValidateSet('TRC','DBG','INF','WRN','ERR','FTL')][string]$Level='INF', [Parameter()][ValidateSet('TRC','DBG','INF','WRN','ERR','FTL')][string]$MinLevel ) if ($null -eq $Message) { $Message = [string]::Empty } $sevMap=@{TRC=0;DBG=1;INF=2;WRN=3;ERR=4;FTL=5} if(-not $PSBoundParameters.ContainsKey('MinLevel')){ $gv=Get-Variable ConsoleLogMinLevel -Scope Global -ErrorAction SilentlyContinue $MinLevel=if($gv -and $gv.Value -and -not [string]::IsNullOrEmpty([string]$gv.Value)){[string]$gv.Value}else{'INF'} } $lvl=$Level.ToUpperInvariant() $min=$MinLevel.ToUpperInvariant() $sev=$sevMap[$lvl];if($null -eq $sev){$lvl='INF';$sev=$sevMap['INF']} $gate=$sevMap[$min];if($null -eq $gate){$min='INF';$gate=$sevMap['INF']} if($sev -ge 4 -and $sev -lt $gate -and $gate -ge 4){$lvl=$min;$sev=$gate} if($sev -lt $gate){return} $ts=[DateTime]::UtcNow.ToString('yy-MM-dd HH:mm:ss.ff') $stack=Get-PSCallStack ; $helperName=$MyInvocation.MyCommand.Name ; $helperScript=$MyInvocation.MyCommand.ScriptBlock.File ; $caller=$null if($stack){ # 1: prefer first non-underscore function not defined in the helper's own file for($i=0;$i -lt $stack.Count;$i++){ $f=$stack[$i];$fn=$f.FunctionName;$sn=$f.ScriptName if($fn -and $fn -ne $helperName -and -not $fn.StartsWith('_') -and (-not $helperScript -or -not $sn -or $sn -ne $helperScript)){$caller=$f;break} } # 2: fallback to first non-underscore function (any file) if(-not $caller){ for($i=0;$i -lt $stack.Count;$i++){ $f=$stack[$i];$fn=$f.FunctionName if($fn -and $fn -ne $helperName -and -not $fn.StartsWith('_')){$caller=$f;break} } } # 3: fallback to first non-helper frame not from helper's own file if(-not $caller){ for($i=0;$i -lt $stack.Count;$i++){ $f=$stack[$i];$fn=$f.FunctionName;$sn=$f.ScriptName if($fn -and $fn -ne $helperName -and (-not $helperScript -or -not $sn -or $sn -ne $helperScript)){$caller=$f;break} } } # 4: final fallback to first non-helper frame if(-not $caller){ for($i=0;$i -lt $stack.Count;$i++){ $f=$stack[$i];$fn=$f.FunctionName if($fn -and $fn -ne $helperName){$caller=$f;break} } } } if(-not $caller){$caller=[pscustomobject]@{ScriptName=$PSCommandPath;FunctionName=$null}} $lineNumber=$null ; $p=$caller.PSObject.Properties['ScriptLineNumber'];if($p -and $p.Value){$lineNumber=[string]$p.Value} if(-not $lineNumber){ $p=$caller.PSObject.Properties['Position'] if($p -and $p.Value){ $sp=$p.Value.PSObject.Properties['StartLineNumber'];if($sp -and $sp.Value){$lineNumber=[string]$sp.Value} } } if(-not $lineNumber){ $p=$caller.PSObject.Properties['Location'] if($p -and $p.Value){ $m=[regex]::Match([string]$p.Value,':(\d+)\s+char:','IgnoreCase');if($m.Success -and $m.Groups.Count -gt 1){$lineNumber=$m.Groups[1].Value} } } $file=if($caller.ScriptName){Split-Path -Leaf $caller.ScriptName}else{'cmd'} if($file -ne 'console' -and $lineNumber){$file="{0}:{1}" -f $file,$lineNumber} $prefix="[$ts " $suffix="] [$file] $Message" $cfg=@{TRC=@{Fore='DarkGray';Back=$null};DBG=@{Fore='Cyan';Back=$null};INF=@{Fore='Green';Back=$null};WRN=@{Fore='Yellow';Back=$null};ERR=@{Fore='Red';Back=$null};FTL=@{Fore='Red';Back='DarkRed'}}[$lvl] $fore=$cfg.Fore $back=$cfg.Back $isInteractive = [System.Environment]::UserInteractive if($isInteractive -and ($fore -or $back)){ Write-Host -NoNewline $prefix if($fore -and $back){Write-Host -NoNewline $lvl -ForegroundColor $fore -BackgroundColor $back} elseif($fore){Write-Host -NoNewline $lvl -ForegroundColor $fore} elseif($back){Write-Host -NoNewline $lvl -BackgroundColor $back} Write-Host $suffix } else { Write-Host "$prefix$lvl$suffix" } if($sev -ge 4 -and $ErrorActionPreference -eq 'Stop'){throw ("ConsoleLog.{0}: {1}" -f $lvl,$Message)} } function _Download-File { [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] param( [Parameter(Mandatory=$true)][string]$Uri, [Parameter(Mandatory=$true)][string]$Destination ) # Ensure TLS 1.2 on older stacks (best-effort). try { $spm = [type]::GetType('System.Net.ServicePointManager') if ($spm) { $protoProp = $spm.GetProperty('SecurityProtocol') if ($protoProp) { [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 } } } catch { } # Use WebClient for PS5 compatibility and cross-platform Core availability. $wc = New-Object System.Net.WebClient try { $wc.Headers['User-Agent'] = 'PowerShell-GetGitHubLatestRelease/1.0' $wc.Headers['Accept'] = 'application/octet-stream' $dir = Split-Path -Parent $Destination if ($dir -and -not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } $wc.DownloadFile($Uri, $Destination) } finally { $wc.Dispose() } } function _AllPatternsMatch { [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] param( [Parameter(Mandatory=$true)][string]$Name, [Parameter()][string[]]$Patterns ) if ($null -eq $Patterns -or $Patterns.Count -eq 0) { return $true } for ($i = 0; $i -lt $Patterns.Count; $i++) { $p = $Patterns[$i] if ($null -eq $p -or $p.Length -eq 0) { return $false } if (-not ($Name -like $p)) { return $false } } return $true } function _ContainsAnySubstringCI { [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] param( [Parameter(Mandatory=$true)][string]$Name, [Parameter()][string[]]$Needles ) if ($null -eq $Needles -or $Needles.Count -eq 0) { return $false } for ($i = 0; $i -lt $Needles.Count; $i++) { $n = $Needles[$i] if ($null -ne $n -and $n.Length -gt 0) { if ($Name.IndexOf($n, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) { return $true } } } return $false } # ---- Resolve DownloadFolder default ------------------------------------ if (-not $PSBoundParameters.ContainsKey('DownloadFolder')) { $userHome = [Environment]::GetFolderPath('UserProfile') if ($null -eq $userHome -or $userHome.Length -eq 0) { # Fallback to env var if needed $userHome = $env:USERPROFILE } if ($null -eq $userHome -or $userHome.Length -eq 0) { _Write-StandardMessage -Message "Cannot determine home directory to compute default Downloads path." -Level ERR return } $DownloadFolder = Join-Path -Path $userHome -ChildPath 'Downloads' } # ---- Parse GitHub URL --------------------------------------------------- $owner = $null $repo = $null try { $u = [Uri]$RepoUrl $path = $u.AbsolutePath.Trim('/') # Remove trailing ".git" if present. if ($path.EndsWith('.git', [System.StringComparison]::OrdinalIgnoreCase)) { $path = $path.Substring(0, $path.Length - 4) } $parts = $path.Split('/') if ($parts.Length -lt 2) { throw [System.ArgumentException]::new('Expected URL like https://github.com/owner/repo') } $owner = $parts[0] $repo = $parts[1] } catch { _Write-StandardMessage -Message ("RepoUrl parse failed: {0}" -f $_.Exception.Message) -Level ERR return } # ---- Query GitHub API --------------------------------------------------- $release = $null try { $apiUri = 'https://api.github.com/repos/{0}/{1}/releases/latest' -f $owner, $repo $headers = @{ 'User-Agent' = 'PowerShell-GetGitHubLatestRelease/1.0' 'Accept' = 'application/vnd.github+json' } # Best-effort TLS12 for older PS5; harmless on PS7. try { $spm = [type]::GetType('System.Net.ServicePointManager') if ($spm) { $protoProp = $spm.GetProperty('SecurityProtocol') if ($protoProp) { [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 } } } catch { } $release = Invoke-RestMethod -Uri $apiUri -Headers $headers -Method Get } catch { _Write-StandardMessage -Message ("GitHub API call failed for {0}/{1}: {2}" -f $owner, $repo, $_.Exception.Message) -Level ERR return } if ($null -eq $release) { _Write-StandardMessage -Message "GitHub API returned no data." -Level ERR return } $tag = $null if ($null -ne $release.tag_name -and $release.tag_name.ToString().Length -gt 0) { $tag = $release.tag_name.ToString() } elseif ($null -ne $release.name -and $release.name.ToString().Length -gt 0) { $tag = $release.name.ToString() } else { $tag = 'unknown' } $assets = @() if ($null -ne $release.assets) { # Copy to a PowerShell array to avoid relying on pipeline variables. for ($i = 0; $i -lt $release.assets.Count; $i++) { $assets += ,$release.assets[$i] } } if ($assets.Count -eq 0) { _Write-StandardMessage -Message ("No assets found for latest release {0}/{1} tag {2}." -f $owner, $repo, $tag) -Level WRN return @() } # ---- Filter by Whitelist/BlackList ------------------------------------- $selected = @() for ($i = 0; $i -lt $assets.Count; $i++) { $a = $assets[$i] $n = [string]$a.name if (-not (_AllPatternsMatch -Name $n -Patterns $Whitelist)) { continue } if (_ContainsAnySubstringCI -Name $n -Needles $BlackList) { continue } $selected += ,$a } if ($selected.Count -eq 0) { _Write-StandardMessage -Message "No assets matched allow/deny filters." -Level WRN return @() } # ---- Prepare extraction support if needed ------------------------------- $zipTypeReady = $false if ($Extract.IsPresent) { try { $null = [System.IO.Compression.ZipFile] $zipTypeReady = $true } catch { try { [void][System.Reflection.Assembly]::Load('System.IO.Compression.FileSystem') $null = [System.IO.Compression.ZipFile] $zipTypeReady = $true } catch { _Write-StandardMessage -Message "ZIP extraction requires System.IO.Compression.ZipFile which is unavailable." -Level ERR return } } } # ---- Ensure base folders exist ----------------------------------------- if (-not (Test-Path -LiteralPath $DownloadFolder)) { New-Item -ItemType Directory -Path $DownloadFolder -Force | Out-Null _Write-StandardMessage -Message ("Created directory: {0}" -f $DownloadFolder) } $rootTarget = $DownloadFolder if ($IncludeVersionFolder.IsPresent) { $rootTarget = Join-Path -Path $rootTarget -ChildPath $tag if (-not (Test-Path -LiteralPath $rootTarget)) { New-Item -ItemType Directory -Path $rootTarget -Force | Out-Null _Write-StandardMessage -Message ("Created version directory: {0}" -f $rootTarget) } } # One temp root for this command invocation (used only if Extract). $tempRoot = $null if ($Extract.IsPresent) { $tempRoot = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath ([Guid]::NewGuid().ToString()) New-Item -ItemType Directory -Path $tempRoot -Force | Out-Null } # ---- Process assets ----------------------------------------------------- $results = @() for ($i = 0; $i -lt $selected.Count; $i++) { $asset = $selected[$i] $name = [string]$asset.name $url = [string]$asset.browser_download_url $targetDir = $rootTarget if (-not $NoSubfolder.IsPresent) { $base = [IO.Path]::GetFileNameWithoutExtension($name) if ($base.Length -eq 0) { $base = 'artifact' } $targetDir = Join-Path -Path $targetDir -ChildPath $base } if (-not (Test-Path -LiteralPath $targetDir)) { New-Item -ItemType Directory -Path $targetDir -Force | Out-Null _Write-StandardMessage -Message ("Created directory: {0}" -f $targetDir) } $pathOut = $null $ext = [IO.Path]::GetExtension($name) if ($Extract.IsPresent -and $zipTypeReady -and ($null -ne $ext) -and ($ext.Equals('.zip', [System.StringComparison]::OrdinalIgnoreCase))) { # Download ZIP to temp file, then extract overwrite. $tempZip = Join-Path -Path $tempRoot -ChildPath $name _Download-File -Uri $url -Destination $tempZip _Write-StandardMessage -Message ("Downloaded: {0}" -f $name) try { $zip = [System.IO.Compression.ZipFile]::OpenRead($tempZip) try { for ($e = 0; $e -lt $zip.Entries.Count; $e++) { $entry = $zip.Entries[$e] if ($null -eq $entry.Name -or $entry.Name.Length -eq 0) { # Directory entry in archive; ensure directory exists. $dirPath = Join-Path -Path $targetDir -ChildPath $entry.FullName if (-not (Test-Path -LiteralPath $dirPath)) { New-Item -ItemType Directory -Path $dirPath -Force | Out-Null } continue } $destPath = Join-Path -Path $targetDir -ChildPath $entry.FullName $destDir = Split-Path -Parent $destPath if ($destDir -and -not (Test-Path -LiteralPath $destDir)) { New-Item -ItemType Directory -Path $destDir -Force | Out-Null } [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $destPath, $true) } } finally { $zip.Dispose() } } catch { _Write-StandardMessage -Message ("Extraction failed for {0}: {1}" -f $name, $_.Exception.Message) -Level ERR return } finally { if (Test-Path -LiteralPath $tempZip) { Remove-Item -LiteralPath $tempZip -Force } } _Write-StandardMessage -Message ("Extracted: {0} -> {1}" -f $name, $targetDir) $pathOut = $targetDir } else { $destFile = Join-Path -Path $targetDir -ChildPath $name _Download-File -Uri $url -Destination $destFile _Write-StandardMessage -Message ("Downloaded: {0} -> {1}" -f $name, $destFile) $pathOut = $destFile } $results += [PSCustomObject]@{ Name = $name Version = $tag DownloadUrl = $url Path = $pathOut } } if ($tempRoot -and (Test-Path -LiteralPath $tempRoot)) { try { Remove-Item -LiteralPath $tempRoot -Recurse -Force } catch { } } return $results } |