<# .SYNOPSIS Проверяет либо публикует бинарный образ Balsam в каталоге релизов Gitea. .DESCRIPTION Проверяет входные параметры и образ, получает учётные данные из переменных окружения или Windows Credential Manager, затем обращается к API репозитория. Режим CheckOnly проверяет доступ без изменения удалённого каталога. Секреты не выводятся в журнал; все ошибки завершают скрипт ненулевым кодом через режим Stop. #> param( [Parameter(Mandatory = $true)][string]$ImagePath, [Parameter(Mandatory = $true)][string]$Version, [Parameter(Mandatory = $true)][string]$Notes, [Parameter(Mandatory = $true)] [ValidateSet("tms", "rs485", "can", "stm32")][string]$Transport, [string]$Product = "Balsam 167 peripheral", [string]$GiteaBase = "https://git.rd12.ru", [string]$Owner = "setcorp", [string]$Repository = "SETRD12-Releases", [string]$Branch = "main", [switch]$CheckOnly ) $ErrorActionPreference = "Stop" Set-StrictMode -Version Latest Add-Type -AssemblyName System.Net.Http Add-Type -TypeDefinition @' using System; using System.Runtime.InteropServices; public static class SetCredentialReader { [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] private struct CREDENTIAL { public UInt32 Flags; public UInt32 Type; public IntPtr TargetName; public IntPtr Comment; public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten; public UInt32 CredentialBlobSize; public IntPtr CredentialBlob; public UInt32 Persist; public UInt32 AttributeCount; public IntPtr Attributes; public IntPtr TargetAlias; public IntPtr UserName; } [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern bool CredRead(string target, uint type, uint flags, out IntPtr credential); [DllImport("advapi32.dll", SetLastError = true)] private static extern void CredFree(IntPtr credential); public static string[] Read(string target) { IntPtr pointer; if (!CredRead(target, 1, 0, out pointer)) return null; try { CREDENTIAL value = (CREDENTIAL)Marshal.PtrToStructure(pointer, typeof(CREDENTIAL)); string user = Marshal.PtrToStringUni(value.UserName) ?? ""; string password = value.CredentialBlobSize == 0 ? "" : Marshal.PtrToStringUni(value.CredentialBlob, (int)value.CredentialBlobSize / 2); return new string[] { user, password }; } finally { CredFree(pointer); } } } '@ function Get-AuthHeaders { if ($env:GITEA_TOKEN) { return @{ Authorization = "token $($env:GITEA_TOKEN)" } } if ($env:GITEA_USER -and $env:GITEA_PASSWORD) { $plain = "$($env:GITEA_USER):$($env:GITEA_PASSWORD)" $basic = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($plain)) return @{ Authorization = "Basic $basic" } } $saved = [SetCredentialReader]::Read("SET/SETGUI/Gitea") if ($null -ne $saved -and $saved.Count -eq 2 -and $saved[0] -and $saved[1]) { $plain = "$($saved[0]):$($saved[1])" $basic = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($plain)) Write-Host "Authentication: Windows Credential Manager (SETGUI)" return @{ Authorization = "Basic $basic" } } throw "Gitea credentials not found. Save them in SETGUI, set GITEA_TOKEN, or set GITEA_USER and GITEA_PASSWORD." } function Invoke-GiteaJson { param( [Parameter(Mandatory = $true)][string]$Method, [Parameter(Mandatory = $true)][string]$Path, [object]$Body, [switch]$AllowNotFound ) $parameters = @{ Method = $Method Uri = "$GiteaBase/api/v1$Path" Headers = $script:Headers UseBasicParsing = $true } if ($null -ne $Body) { $parameters.ContentType = "application/json; charset=utf-8" $parameters.Body = $Body | ConvertTo-Json -Depth 20 -Compress } try { return Invoke-RestMethod @parameters } catch { $response = $_.Exception.Response if ($AllowNotFound -and $null -ne $response -and [int]$response.StatusCode -eq 404) { return $null } throw } } function Send-ReleaseAsset { param([long]$ReleaseId, [string]$FilePath, [string]$AssetName) $client = [Net.Http.HttpClient]::new() try { foreach ($entry in $script:Headers.GetEnumerator()) { [void]$client.DefaultRequestHeaders.TryAddWithoutValidation($entry.Key, $entry.Value) } $encodedName = [Uri]::EscapeDataString($AssetName) $uri = "$GiteaBase/api/v1/repos/$Owner/$Repository/releases/$ReleaseId/assets?name=$encodedName" $form = [Net.Http.MultipartFormDataContent]::new() try { $stream = [IO.File]::OpenRead($FilePath) $content = [Net.Http.StreamContent]::new($stream) $content.Headers.ContentType = [Net.Http.Headers.MediaTypeHeaderValue]::new("application/octet-stream") $form.Add($content, "attachment", $AssetName) $response = $client.PostAsync($uri, $form).GetAwaiter().GetResult() $text = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() if (-not $response.IsSuccessStatusCode) { throw "Asset upload failed: HTTP $([int]$response.StatusCode): $text" } } finally { if ($null -ne $form) { $form.Dispose() } } } finally { $client.Dispose() } } $resolvedImage = (Resolve-Path -LiteralPath $ImagePath).Path if ([IO.Path]::GetExtension($resolvedImage) -notin @(".bin", ".hex")) { throw "Firmware image must have .bin or .hex extension." } $imageInfo = Get-Item -LiteralPath $resolvedImage if ($imageInfo.Length -le 0 -or $imageInfo.Length -gt 134217728) { throw "Firmware image is empty or exceeds 128 MiB." } $match = [regex]::Match($Version, '^(\d+)\.(\d+)\.(\d+)$') if (-not $match.Success) { throw "Version must use MAJOR.MINOR.PATCH format, for example 1.0.0." } $major = [long]$match.Groups[1].Value $minor = [long]$match.Groups[2].Value $patch = [long]$match.Groups[3].Value if ($minor -gt 999 -or $patch -gt 999) { throw "MINOR and PATCH must be between 0 and 999." } $versionCode = $major * 1000000 + $minor * 1000 + $patch $sha256 = [Security.Cryptography.SHA256]::Create() try { $hashStream = [IO.File]::OpenRead($resolvedImage) try { $hashBytes = $sha256.ComputeHash($hashStream) } finally { $hashStream.Dispose() } } finally { $sha256.Dispose() } $hash = ([BitConverter]::ToString($hashBytes) -replace '-', '').ToLowerInvariant() $safeProduct = ($Product.ToLowerInvariant() -replace '[^a-z0-9]+', '-').Trim('-') $tag = "$safeProduct-v$Version" $extension = [IO.Path]::GetExtension($resolvedImage).ToLowerInvariant() $assetName = "$safeProduct-$Version$extension" $encodedTag = [Uri]::EscapeDataString($tag) $script:Headers = Get-AuthHeaders $script:Headers["User-Agent"] = "Balsam-firmware-publisher/1.0" Write-Host "Image: $resolvedImage" Write-Host "Size: $($imageInfo.Length) bytes" Write-Host "SHA: $hash" Write-Host "Tag: $tag" if ($CheckOnly) { $repositoryInfo = Invoke-GiteaJson GET "/repos/$Owner/$Repository" $contentInfo = Invoke-GiteaJson GET "/repos/$Owner/$Repository/contents/update.json`?ref=$Branch" if (-not $repositoryInfo.full_name -or -not $contentInfo.sha) { throw "Gitea access check returned incomplete repository data." } Write-Host "CHECK OK: access to $($repositoryInfo.full_name), update.json and local image is valid." exit 0 } $release = Invoke-GiteaJson GET "/repos/$Owner/$Repository/releases/tags/$encodedTag" -AllowNotFound if ($null -eq $release) { $release = Invoke-GiteaJson POST "/repos/$Owner/$Repository/releases" ([ordered]@{ tag_name = $tag target_commitish = $Branch name = "$Product $Version" body = $Notes draft = $false prerelease = $false }) } else { $release = Invoke-GiteaJson PATCH "/repos/$Owner/$Repository/releases/$($release.id)" ([ordered]@{ name = "$Product $Version" body = $Notes draft = $false prerelease = $false }) } $assets = Invoke-GiteaJson GET "/repos/$Owner/$Repository/releases/$($release.id)/assets" $oldAsset = @($assets) | Where-Object { $_.name -eq $assetName } | Select-Object -First 1 if ($null -ne $oldAsset) { Invoke-GiteaJson DELETE "/repos/$Owner/$Repository/releases/$($release.id)/assets/$($oldAsset.id)" | Out-Null } Send-ReleaseAsset $release.id $resolvedImage $assetName $manifestPath = "update.json" $contentInfo = Invoke-GiteaJson GET "/repos/$Owner/$Repository/contents/$manifestPath`?ref=$Branch" $manifestText = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(($contentInfo.content -replace '\s', ''))) $manifest = $manifestText | ConvertFrom-Json if ($null -eq $manifest.firmware) { $manifest | Add-Member NoteProperty firmware ([pscustomobject]@{ catalogVersion = 1; releases = @() }) } if ($null -eq $manifest.firmware.releases) { $manifest.firmware | Add-Member NoteProperty releases @() } $releases = @($manifest.firmware.releases) | Where-Object { -not ($_.product -eq $Product -and [long]$_.versionCode -eq $versionCode) } $downloadUrl = "$GiteaBase/$Owner/$Repository/releases/download/$tag/$assetName" $entry = [pscustomobject][ordered]@{ product = $Product versionCode = $versionCode versionName = $Version imageUrl = $downloadUrl fileName = $assetName sha256 = $hash transport = $Transport notes = $Notes } $manifest.firmware.releases = @($entry) + $releases $manifest.firmware.catalogVersion = [long]$manifest.firmware.catalogVersion + 1 $updatedJson = $manifest | ConvertTo-Json -Depth 20 $encodedManifest = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($updatedJson + "`n")) Invoke-GiteaJson PUT "/repos/$Owner/$Repository/contents/$manifestPath" ([ordered]@{ branch = $Branch sha = $contentInfo.sha message = "Publish firmware $Product $Version" content = $encodedManifest }) | Out-Null Write-Host "Published: $downloadUrl" Write-Host "Catalog: $GiteaBase/$Owner/$Repository/raw/branch/$Branch/update.json"