Update CCS12 peripheral firmware and documentation
This commit is contained in:
263
scripts/publish-firmware.ps1
Normal file
263
scripts/publish-firmware.ps1
Normal file
@@ -0,0 +1,263 @@
|
||||
<#
|
||||
.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"
|
||||
Reference in New Issue
Block a user