Files
LanMountainDesktop/scripts/Publish-Plonds.ps1
2026-04-21 16:12:47 +08:00

1045 lines
35 KiB
PowerShell

param(
[Parameter(Mandatory = $true)]
[string]$Version,
[Parameter(Mandatory = $true)]
[string]$AppArtifactsRoot,
[Parameter(Mandatory = $true)]
[string]$InstallerArtifactsRoot,
[Parameter(Mandatory = $true)]
[string]$OutputDir,
[Parameter(Mandatory = $true)]
[string]$PrivateKeyPath,
[Parameter(Mandatory = $false)]
[string]$Channel = "stable",
[Parameter(Mandatory = $false)]
[string]$S3Endpoint = "",
[Parameter(Mandatory = $false)]
[string]$S3Bucket = "",
[Parameter(Mandatory = $false)]
[string]$S3Region = "",
[Parameter(Mandatory = $false)]
[string]$IncrementalStrategy = "release-payload",
[Parameter(Mandatory = $false)]
[string]$PublishIncrementalRelease = "true",
[Parameter(Mandatory = $false)]
[string]$BaselineRef = "",
[Parameter(Mandatory = $false)]
[string]$GitHubRepository = "",
[Parameter(Mandatory = $false)]
[string]$GitHubTag = "",
[Parameter(Mandatory = $false)]
[string]$MirrorInstallersToS3 = "false",
[Parameter(Mandatory = $false)]
[string]$UploadMetaToS3 = "true"
)
$ErrorActionPreference = "Stop"
function ConvertTo-Boolean {
param(
[Parameter(Mandatory = $true)]
[string]$Value,
[Parameter(Mandatory = $false)]
[bool]$DefaultValue = $false
)
if ([string]::IsNullOrWhiteSpace($Value)) {
return $DefaultValue
}
return $Value.Trim().ToLowerInvariant() -in @("1", "true", "yes", "y", "on")
}
function Get-GitHubReleaseBaseUrl {
param(
[Parameter(Mandatory = $false)]
[string]$Repository,
[Parameter(Mandatory = $false)]
[string]$Tag
)
if ([string]::IsNullOrWhiteSpace($Repository) -or [string]::IsNullOrWhiteSpace($Tag)) {
return $null
}
$normalizedRepository = $Repository.Trim().Trim('/')
$normalizedTag = $Tag.Trim()
if ($normalizedTag.StartsWith("refs/tags/", [System.StringComparison]::OrdinalIgnoreCase)) {
$normalizedTag = $normalizedTag.Substring("refs/tags/".Length)
}
return "https://github.com/$normalizedRepository/releases/download/$normalizedTag"
}
function Get-PlatformConfigurations {
return @(
@{
Platform = "windows-x64"
ArtifactName = "app-payload-windows-x64"
},
@{
Platform = "windows-x86"
ArtifactName = "app-payload-windows-x86"
},
@{
Platform = "linux-x64"
ArtifactName = "app-payload-linux-x64"
}
)
}
function Resolve-AppDirectory {
param(
[Parameter(Mandatory = $true)]
[string]$SearchRoot,
[Parameter(Mandatory = $true)]
[string]$Version
)
$preferred = Get-ChildItem -LiteralPath $SearchRoot -Recurse -Directory -Filter "app-$Version" -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($preferred) {
return $preferred.FullName
}
$fallback = Get-ChildItem -LiteralPath $SearchRoot -Recurse -Directory -Filter "app-*" -ErrorAction SilentlyContinue |
Sort-Object FullName |
Select-Object -First 1
return $fallback?.FullName
}
function Clear-Directory {
param([Parameter(Mandatory = $true)][string]$Path)
if (Test-Path -LiteralPath $Path) {
Get-ChildItem -LiteralPath $Path -Force -ErrorAction SilentlyContinue | ForEach-Object {
Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction SilentlyContinue
}
}
else {
New-Item -ItemType Directory -Path $Path -Force | Out-Null
}
}
function Invoke-AwsCommandIfPossible {
param(
[Parameter(Mandatory = $true)]
[string[]]$Arguments,
[Parameter(Mandatory = $false)]
[switch]$IgnoreFailure
)
if ([string]::IsNullOrWhiteSpace($S3Endpoint) -or [string]::IsNullOrWhiteSpace($S3Bucket)) {
return $null
}
$previousRequestChecksumCalculation = $env:AWS_REQUEST_CHECKSUM_CALCULATION
$previousResponseChecksumValidation = $env:AWS_RESPONSE_CHECKSUM_VALIDATION
$env:AWS_REQUEST_CHECKSUM_CALCULATION = "WHEN_REQUIRED"
$env:AWS_RESPONSE_CHECKSUM_VALIDATION = "WHEN_REQUIRED"
try {
if ($IgnoreFailure) {
return (& aws @Arguments 2>$null)
}
return (& aws @Arguments)
}
finally {
if ($null -eq $previousRequestChecksumCalculation) {
Remove-Item Env:AWS_REQUEST_CHECKSUM_CALCULATION -ErrorAction SilentlyContinue
}
else {
$env:AWS_REQUEST_CHECKSUM_CALCULATION = $previousRequestChecksumCalculation
}
if ($null -eq $previousResponseChecksumValidation) {
Remove-Item Env:AWS_RESPONSE_CHECKSUM_VALIDATION -ErrorAction SilentlyContinue
}
else {
$env:AWS_RESPONSE_CHECKSUM_VALIDATION = $previousResponseChecksumValidation
}
}
}
function Get-S3Key {
param(
[Parameter(Mandatory = $true)]
[string]$Prefix,
[Parameter(Mandatory = $true)]
[string]$RelativePath
)
$trimmedPrefix = $Prefix.Trim('/').Replace('\', '/')
$trimmedRelativePath = $RelativePath.TrimStart('\', '/').Replace('\', '/')
return "$trimmedPrefix/$trimmedRelativePath"
}
function Get-RelativePath {
param(
[Parameter(Mandatory = $true)]
[string]$Root,
[Parameter(Mandatory = $true)]
[string]$Path
)
$rootPath = [System.IO.Path]::GetFullPath($Root)
if (-not $rootPath.EndsWith([System.IO.Path]::DirectorySeparatorChar)) {
$rootPath += [System.IO.Path]::DirectorySeparatorChar
}
$pathValue = [System.IO.Path]::GetFullPath($Path)
return [System.IO.Path]::GetRelativePath($rootPath, $pathValue)
}
function Test-S3ObjectExists {
param([Parameter(Mandatory = $true)][string]$Key)
if ([string]::IsNullOrWhiteSpace($S3Endpoint) -or [string]::IsNullOrWhiteSpace($S3Bucket)) {
return $false
}
Invoke-AwsCommandIfPossible -Arguments @(
"--endpoint-url", $S3Endpoint,
"--region", $S3Region,
"s3api", "head-object",
"--bucket", $S3Bucket,
"--key", $Key.Trim('/').Replace('\', '/')
) -IgnoreFailure | Out-Null
return $LASTEXITCODE -eq 0
}
function Copy-S3ObjectToLocal {
param(
[Parameter(Mandatory = $true)]
[string]$Key,
[Parameter(Mandatory = $true)]
[string]$DestinationPath
)
New-Item -ItemType Directory -Path ([System.IO.Path]::GetDirectoryName($DestinationPath)) -Force | Out-Null
Invoke-AwsCommandIfPossible -Arguments @(
"--endpoint-url", $S3Endpoint,
"--region", $S3Region,
"s3", "cp",
"s3://$S3Bucket/$($Key.Trim('/').Replace('\', '/'))",
$DestinationPath,
"--only-show-errors"
) -IgnoreFailure | Out-Null
return ($LASTEXITCODE -eq 0 -and (Test-Path -LiteralPath $DestinationPath))
}
function Get-S3JsonDocument {
param([Parameter(Mandatory = $true)][string]$Key)
$tempPath = Join-Path $OutputDir ("_tmp_" + [System.Guid]::NewGuid().ToString("N") + ".json")
try {
if (-not (Copy-S3ObjectToLocal -Key $Key -DestinationPath $tempPath)) {
return $null
}
return Get-Content -LiteralPath $tempPath -Raw | ConvertFrom-Json -AsHashtable
}
finally {
Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue
}
}
function New-ZipFromDirectory {
param(
[Parameter(Mandatory = $true)]
[string]$SourceDirectory,
[Parameter(Mandatory = $true)]
[string]$DestinationPath
)
Add-Type -AssemblyName System.IO.Compression.FileSystem
if (Test-Path -LiteralPath $DestinationPath) {
Remove-Item -LiteralPath $DestinationPath -Force
}
New-Item -ItemType Directory -Path ([System.IO.Path]::GetDirectoryName($DestinationPath)) -Force | Out-Null
[System.IO.Compression.ZipFile]::CreateFromDirectory($SourceDirectory, $DestinationPath, [System.IO.Compression.CompressionLevel]::Optimal, $false)
}
function Expand-PayloadSnapshot {
param(
[Parameter(Mandatory = $true)]
[string]$Platform,
[Parameter(Mandatory = $true)]
[string]$BaselineVersion,
[Parameter(Mandatory = $true)]
[string]$DestinationPath
)
$payloadKey = "lanmountain/update/payloads/$Platform/$BaselineVersion/app-payload.zip"
if (-not (Test-S3ObjectExists -Key $payloadKey)) {
return $false
}
$tempZip = Join-Path $OutputDir ("payload-" + $Platform + "-" + $BaselineVersion + ".zip")
try {
if (-not (Copy-S3ObjectToLocal -Key $payloadKey -DestinationPath $tempZip)) {
return $false
}
Clear-Directory -Path $DestinationPath
Expand-Archive -LiteralPath $tempZip -DestinationPath $DestinationPath -Force
return $true
}
finally {
Remove-Item -LiteralPath $tempZip -Force -ErrorAction SilentlyContinue
}
}
function Restore-LegacyBaseline {
param(
[Parameter(Mandatory = $true)]
[string]$Platform,
[Parameter(Mandatory = $true)]
[string]$DestinationPath,
[Parameter(Mandatory = $true)]
[string]$VersionFilePath
)
Clear-Directory -Path $DestinationPath
Remove-Item -LiteralPath $VersionFilePath -Force -ErrorAction SilentlyContinue
if ([string]::IsNullOrWhiteSpace($S3Endpoint) -or [string]::IsNullOrWhiteSpace($S3Bucket)) {
return
}
Invoke-AwsCommandIfPossible -Arguments @(
"--endpoint-url", $S3Endpoint,
"--region", $S3Region,
"s3", "sync",
"s3://$S3Bucket/lanmountain/update/baselines/$Platform/current/",
$DestinationPath,
"--only-show-errors"
) -IgnoreFailure | Out-Null
Copy-S3ObjectToLocal -Key "lanmountain/update/baselines/$Platform/version.txt" -DestinationPath $VersionFilePath | Out-Null
}
function ConvertTo-NormalizedVersion {
param([Parameter(Mandatory = $false)][string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) {
return $null
}
$trimmed = $Value.Trim()
if ($trimmed.StartsWith("refs/tags/", [System.StringComparison]::OrdinalIgnoreCase)) {
$trimmed = $trimmed.Substring("refs/tags/".Length)
}
if ($trimmed.StartsWith("v", [System.StringComparison]::OrdinalIgnoreCase)) {
$trimmed = $trimmed.Substring(1)
}
if ($trimmed -match '^\d+(\.\d+){1,3}$') {
return $trimmed
}
return $null
}
function Resolve-GitTagFromRef {
param([Parameter(Mandatory = $true)][string]$GitRef)
$tag = (& git describe --tags --match "v*" --abbrev=0 $GitRef 2>$null)
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($tag)) {
return $null
}
return $tag.Trim()
}
function Get-LatestChannelPointer {
param([Parameter(Mandatory = $true)][string]$Platform)
return Get-S3JsonDocument -Key "lanmountain/update/meta/channels/$Channel/$Platform/latest.json"
}
function Get-CommitRangeInfo {
param(
[Parameter(Mandatory = $true)]
[string]$RangeStart,
[Parameter(Mandatory = $true)]
[string]$RangeEnd
)
$files = (& git diff --name-only "$RangeStart..$RangeEnd" 2>$null)
if ($LASTEXITCODE -ne 0) {
return @{
Start = $RangeStart
End = $RangeEnd
ChangeCount = 0
HasPotentialPayloadImpact = $true
RequiresComponentExpansion = $true
SamplePaths = ""
}
}
$changes = @($files | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
$ignoredPrefixes = @(".github/", ".trae/", "docs/", "PenguinLogisticsOnlineNetworkDistributionSystem/")
$ignoredExtensions = @(".md", ".txt")
$expansionPrefixes = @(
"LanMountainDesktop/",
"LanMountainDesktop.Launcher/",
"LanMountainDesktop.Appearance/",
"LanMountainDesktop.PluginSdk/",
"LanMountainDesktop.Settings.Core/",
"LanMountainDesktop.Shared.Contracts/",
"LanMountainDesktop.Tests/",
"scripts/"
)
$expansionExtensions = @(".csproj", ".props", ".targets", ".sln", ".slnx", ".json", ".axaml", ".resx")
$impactfulChanges = [System.Collections.Generic.List[string]]::new()
$requiresExpansion = $false
foreach ($change in $changes) {
$normalized = $change.Replace('\', '/')
$extension = [System.IO.Path]::GetExtension($normalized)
$isIgnored = $false
foreach ($ignoredPrefix in $ignoredPrefixes) {
if ($normalized.StartsWith($ignoredPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
$isIgnored = $true
break
}
}
if (-not $isIgnored -and $ignoredExtensions -contains $extension.ToLowerInvariant()) {
$isIgnored = $true
}
if ($isIgnored) {
continue
}
$impactfulChanges.Add($normalized)
foreach ($expansionPrefix in $expansionPrefixes) {
if ($normalized.StartsWith($expansionPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
$requiresExpansion = $true
break
}
}
if ($requiresExpansion -or $expansionExtensions -contains $extension.ToLowerInvariant()) {
$requiresExpansion = $true
}
}
return @{
Start = $RangeStart
End = $RangeEnd
ChangeCount = $changes.Count
HasPotentialPayloadImpact = ($impactfulChanges.Count -gt 0)
RequiresComponentExpansion = $requiresExpansion
SamplePaths = (($impactfulChanges | Select-Object -First 10) -join "; ")
}
}
function Update-JsonMetadata {
param(
[Parameter(Mandatory = $true)]
[string]$Path,
[Parameter(Mandatory = $true)]
[hashtable]$Metadata
)
$document = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json -AsHashtable
if (-not $document.ContainsKey("metadata") -or $null -eq $document.metadata) {
$document.metadata = @{}
}
foreach ($key in $Metadata.Keys) {
if ($null -ne $Metadata[$key] -and -not [string]::IsNullOrWhiteSpace([string]$Metadata[$key])) {
$document.metadata[$key] = [string]$Metadata[$key]
}
}
$document | ConvertTo-Json -Depth 64 | Set-Content -LiteralPath $Path -Encoding utf8NoBOM
}
function Get-FileMapChangeSummary {
param([Parameter(Mandatory = $true)][string]$Path)
$document = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json -AsHashtable
$summary = @{
Add = 0
Replace = 0
Reuse = 0
Delete = 0
}
foreach ($component in @($document.components)) {
foreach ($file in @($component.files)) {
$operation = [string]$file.op
if ($summary.ContainsKey($operation.Substring(0, 1).ToUpperInvariant() + $operation.Substring(1))) {
$summary[$operation.Substring(0, 1).ToUpperInvariant() + $operation.Substring(1)]++
}
}
}
return $summary
}
function Upload-DirectoryToS3 {
param(
[Parameter(Mandatory = $true)]
[string]$LocalRoot,
[Parameter(Mandatory = $true)]
[string]$RemotePrefix,
[Parameter(Mandatory = $false)]
[switch]$SkipExisting
)
if (-not (Test-Path -LiteralPath $LocalRoot)) {
Write-Host "Skipping missing upload root: $LocalRoot"
return
}
$files = Get-ChildItem -LiteralPath $LocalRoot -Recurse -File | Sort-Object FullName
if ($files.Count -eq 0) {
Write-Host "No files found under $LocalRoot; skipping upload."
return
}
$index = 0
foreach ($file in $files) {
$index++
$relativePath = Get-RelativePath -Root $LocalRoot -Path $file.FullName
$key = Get-S3Key -Prefix $RemotePrefix -RelativePath $relativePath
if ($SkipExisting -and (Test-S3ObjectExists -Key $key)) {
if ($index -eq 1 -or $index % 25 -eq 0 -or $index -eq $files.Count) {
Write-Host "Skipping existing $index/$($files.Count): $key"
}
continue
}
if ($index -eq 1 -or $index % 25 -eq 0 -or $index -eq $files.Count) {
Write-Host "Uploading $index/$($files.Count): $key"
}
Invoke-AwsCommandIfPossible -Arguments @(
"--endpoint-url", $S3Endpoint,
"--region", $S3Region,
"s3api", "put-object",
"--bucket", $S3Bucket,
"--key", $key,
"--body", $file.FullName
) | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "Failed to upload $key"
}
}
}
function Upload-InstallerDirectoryToS3 {
param(
[Parameter(Mandatory = $true)]
[string]$LocalRoot,
[Parameter(Mandatory = $true)]
[string]$RemotePrefix
)
if (-not (Test-Path -LiteralPath $LocalRoot)) {
Write-Host "Skipping missing installer upload root: $LocalRoot"
return
}
$files = Get-ChildItem -LiteralPath $LocalRoot -Recurse -File | Sort-Object FullName
if ($files.Count -eq 0) {
Write-Host "No installer files found under $LocalRoot; skipping installer upload."
return
}
$tempDir = Join-Path $OutputDir ("_aws-installer-config-" + [System.Guid]::NewGuid().ToString("N"))
$tempConfigPath = Join-Path $tempDir "config"
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
@"
[default]
s3 =
preferred_transfer_client = classic
addressing_style = path
max_concurrent_requests = 4
max_queue_size = 32
multipart_threshold = 64MB
multipart_chunksize = 32MB
payload_signing_enabled = false
"@ | Set-Content -LiteralPath $tempConfigPath -Encoding ascii
$previousConfigFile = $env:AWS_CONFIG_FILE
$previousRetryMode = $env:AWS_RETRY_MODE
$previousMaxAttempts = $env:AWS_MAX_ATTEMPTS
$env:AWS_CONFIG_FILE = $tempConfigPath
$env:AWS_RETRY_MODE = "adaptive"
$env:AWS_MAX_ATTEMPTS = "6"
try {
$index = 0
foreach ($file in $files) {
$index++
$relativePath = Get-RelativePath -Root $LocalRoot -Path $file.FullName
$key = Get-S3Key -Prefix $RemotePrefix -RelativePath $relativePath
if (Test-S3ObjectExists -Key $key) {
if ($index -eq 1 -or $index % 10 -eq 0 -or $index -eq $files.Count) {
Write-Host "Skipping existing installer $index/$($files.Count): $key"
}
continue
}
Write-Host "Uploading installer $index/$($files.Count): $key"
Invoke-AwsCommandIfPossible -Arguments @(
"--cli-connect-timeout", "60",
"--cli-read-timeout", "0",
"--endpoint-url", $S3Endpoint,
"--region", $S3Region,
"s3", "cp",
$file.FullName,
"s3://$S3Bucket/$key",
"--only-show-errors",
"--no-progress"
) -IgnoreFailure | Out-Null
if ($LASTEXITCODE -eq 0) {
continue
}
Write-Warning "Multipart installer upload failed for $key, falling back to put-object."
Invoke-AwsCommandIfPossible -Arguments @(
"--endpoint-url", $S3Endpoint,
"--region", $S3Region,
"s3api", "put-object",
"--bucket", $S3Bucket,
"--key", $key,
"--body", $file.FullName
) | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "Failed to upload installer mirror: $key"
}
}
}
finally {
if ($null -eq $previousConfigFile) {
Remove-Item Env:AWS_CONFIG_FILE -ErrorAction SilentlyContinue
}
else {
$env:AWS_CONFIG_FILE = $previousConfigFile
}
if ($null -eq $previousRetryMode) {
Remove-Item Env:AWS_RETRY_MODE -ErrorAction SilentlyContinue
}
else {
$env:AWS_RETRY_MODE = $previousRetryMode
}
if ($null -eq $previousMaxAttempts) {
Remove-Item Env:AWS_MAX_ATTEMPTS -ErrorAction SilentlyContinue
}
else {
$env:AWS_MAX_ATTEMPTS = $previousMaxAttempts
}
Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue
}
}
if (-not (Test-Path -LiteralPath $PrivateKeyPath)) {
throw "Private key file not found: $PrivateKeyPath"
}
$toolProject = Join-Path $PSScriptRoot "..\PenguinLogisticsOnlineNetworkDistributionSystem\src\Plonds.Tool\Plonds.Tool.csproj"
if (-not (Test-Path -LiteralPath $toolProject)) {
throw "PLONDS tool project not found: $toolProject"
}
$supportedPlatforms = Get-PlatformConfigurations
$publishedRoot = Join-Path $OutputDir "published"
$releaseAssetsRoot = Join-Path $OutputDir "release-assets"
$baselineRoot = Join-Path $OutputDir "_baselines"
$legacyRoot = Join-Path $OutputDir "legacy"
$publishIncremental = ConvertTo-Boolean -Value $PublishIncrementalRelease -DefaultValue $true
$isFullPayloadRelease = -not $publishIncremental
$mirrorInstallers = ConvertTo-Boolean -Value $MirrorInstallersToS3 -DefaultValue $false
$uploadMetaToS3 = ConvertTo-Boolean -Value $UploadMetaToS3 -DefaultValue $true
$gitHubReleaseBaseUrl = Get-GitHubReleaseBaseUrl -Repository $GitHubRepository -Tag $GitHubTag
$sourceCommit = (& git rev-parse HEAD 2>$null)
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($sourceCommit)) {
$sourceCommit = ""
}
else {
$sourceCommit = $sourceCommit.Trim()
}
New-Item -ItemType Directory -Path $publishedRoot -Force | Out-Null
New-Item -ItemType Directory -Path $releaseAssetsRoot -Force | Out-Null
New-Item -ItemType Directory -Path $baselineRoot -Force | Out-Null
New-Item -ItemType Directory -Path $legacyRoot -Force | Out-Null
$repoBaseUrl = if ([string]::IsNullOrWhiteSpace($S3Endpoint) -or [string]::IsNullOrWhiteSpace($S3Bucket)) {
$null
}
else {
"$($S3Endpoint.TrimEnd('/'))/$S3Bucket/lanmountain/update/repo/sha256"
}
$installerBaseUrl = if (-not [string]::IsNullOrWhiteSpace($gitHubReleaseBaseUrl)) {
$gitHubReleaseBaseUrl
}
elseif ([string]::IsNullOrWhiteSpace($S3Endpoint) -or [string]::IsNullOrWhiteSpace($S3Bucket)) {
$null
}
else {
"$($S3Endpoint.TrimEnd('/'))/$S3Bucket/lanmountain/update/installers"
}
$installerMirrorMode = if (-not [string]::IsNullOrWhiteSpace($gitHubReleaseBaseUrl)) {
"github-release"
}
elseif ($mirrorInstallers -and -not [string]::IsNullOrWhiteSpace($installerBaseUrl)) {
"s3"
}
else {
"none"
}
$resolvedBaselineVersionOverride = ConvertTo-NormalizedVersion -Value $BaselineRef
$resolvedBaselineRefOverride = if ([string]::IsNullOrWhiteSpace($BaselineRef)) {
$null
}
elseif (-not [string]::IsNullOrWhiteSpace($resolvedBaselineVersionOverride)) {
"v$resolvedBaselineVersionOverride"
}
else {
$BaselineRef.Trim()
}
$platformStates = @{}
foreach ($config in $supportedPlatforms) {
$platform = $config.Platform
$platformBaselineRoot = Join-Path $baselineRoot $platform
$baselineCurrentDir = Join-Path $platformBaselineRoot "current"
$baselineVersionPath = Join-Path $platformBaselineRoot "version.txt"
$snapshotRoot = Join-Path $platformBaselineRoot "previous-snapshot"
$emptyRoot = Join-Path $platformBaselineRoot "empty"
New-Item -ItemType Directory -Path $platformBaselineRoot -Force | Out-Null
Clear-Directory -Path $baselineCurrentDir
Clear-Directory -Path $snapshotRoot
Clear-Directory -Path $emptyRoot
$latestPointer = $null
$resolvedBaselineVersion = $resolvedBaselineVersionOverride
$resolvedBaselineRef = $resolvedBaselineRefOverride
if (-not $resolvedBaselineVersion) {
if (-not [string]::IsNullOrWhiteSpace($BaselineRef)) {
$resolvedBaselineRef = if ($resolvedBaselineRef) { $resolvedBaselineRef } else { $BaselineRef.Trim() }
$tag = Resolve-GitTagFromRef -GitRef $BaselineRef.Trim()
if ($tag) {
$resolvedBaselineVersion = ConvertTo-NormalizedVersion -Value $tag
if (-not $resolvedBaselineRef) {
$resolvedBaselineRef = $tag
}
}
}
else {
$latestPointer = Get-LatestChannelPointer -Platform $platform
if ($latestPointer) {
$resolvedBaselineVersion = [string]$latestPointer.version
$resolvedBaselineRef = if ([string]::IsNullOrWhiteSpace([string]$latestPointer.version)) { $null } else { "v$($latestPointer.version)" }
}
}
}
$baselineSource = "none"
if ($isFullPayloadRelease) {
"0.0.0" | Set-Content -LiteralPath $baselineVersionPath -Encoding ascii
$baselineSource = "empty"
}
else {
$restored = $false
if (-not [string]::IsNullOrWhiteSpace($resolvedBaselineVersion)) {
$restored = Expand-PayloadSnapshot -Platform $platform -BaselineVersion $resolvedBaselineVersion -DestinationPath $baselineCurrentDir
if ($restored) {
$baselineSource = "payload"
$resolvedBaselineRef = if ($resolvedBaselineRef) { $resolvedBaselineRef } else { "v$resolvedBaselineVersion" }
}
}
if (-not $restored) {
Restore-LegacyBaseline -Platform $platform -DestinationPath $baselineCurrentDir -VersionFilePath $baselineVersionPath
$legacyVersion = if (Test-Path -LiteralPath $baselineVersionPath) {
(Get-Content -LiteralPath $baselineVersionPath -Raw).Trim()
}
else {
""
}
if (-not [string]::IsNullOrWhiteSpace($legacyVersion)) {
$resolvedBaselineVersion = $legacyVersion
$resolvedBaselineRef = if ($resolvedBaselineRef) { $resolvedBaselineRef } else { "v$legacyVersion" }
$baselineSource = "legacy-baseline"
}
else {
"0.0.0" | Set-Content -LiteralPath $baselineVersionPath -Encoding ascii
$resolvedBaselineVersion = "0.0.0"
$baselineSource = "empty"
}
}
if (-not (Test-Path -LiteralPath $baselineVersionPath)) {
$versionToPersist = if ([string]::IsNullOrWhiteSpace($resolvedBaselineVersion)) { "0.0.0" } else { $resolvedBaselineVersion }
$versionToPersist | Set-Content -LiteralPath $baselineVersionPath -Encoding ascii
}
}
$baselineItems = @(Get-ChildItem -LiteralPath $baselineCurrentDir -Force -ErrorAction SilentlyContinue)
if ($baselineItems.Count -gt 0) {
foreach ($baselineItem in $baselineItems) {
Copy-Item -LiteralPath $baselineItem.FullName -Destination $snapshotRoot -Recurse -Force
}
$legacyPreviousDir = $snapshotRoot
}
else {
$legacyPreviousDir = $emptyRoot
}
$commitInfo = @{
Start = $null
End = $sourceCommit
ChangeCount = 0
HasPotentialPayloadImpact = $true
RequiresComponentExpansion = $true
SamplePaths = ""
}
if ($IncrementalStrategy -eq "commit-range") {
$rangeStart = if (-not [string]::IsNullOrWhiteSpace($resolvedBaselineRef)) {
$resolvedBaselineRef
}
elseif (-not [string]::IsNullOrWhiteSpace($resolvedBaselineVersion)) {
"v$resolvedBaselineVersion"
}
else {
$null
}
if (-not [string]::IsNullOrWhiteSpace($rangeStart) -and -not [string]::IsNullOrWhiteSpace($sourceCommit)) {
$commitInfo = Get-CommitRangeInfo -RangeStart $rangeStart -RangeEnd $sourceCommit
}
}
$platformStates[$platform] = @{
Platform = $platform
ArtifactName = $config.ArtifactName
BaselineVersion = if ([string]::IsNullOrWhiteSpace($resolvedBaselineVersion)) { "0.0.0" } else { $resolvedBaselineVersion }
BaselineRef = $resolvedBaselineRef
BaselineSource = $baselineSource
LegacyPreviousDir = $legacyPreviousDir
CommitInfo = $commitInfo
}
Write-Host "Prepared baseline for $platform => version=$($platformStates[$platform].BaselineVersion), source=$baselineSource, strategy=$IncrementalStrategy"
}
$publishArguments = @(
"run",
"--project", $toolProject,
"--",
"publish",
"--version", $Version,
"--app-artifacts-root", $AppArtifactsRoot,
"--installer-artifacts-root", $InstallerArtifactsRoot,
"--output-dir", $publishedRoot,
"--private-key", $PrivateKeyPath,
"--baseline-root", $baselineRoot,
"--channel", $Channel,
"--incremental-strategy", $IncrementalStrategy,
"--is-full-payload-release", $isFullPayloadRelease.ToString().ToLowerInvariant()
)
if (-not [string]::IsNullOrWhiteSpace($repoBaseUrl)) {
$publishArguments += @("--repo-base-url", $repoBaseUrl)
}
if (-not [string]::IsNullOrWhiteSpace($installerBaseUrl)) {
$publishArguments += @("--installer-base-url", $installerBaseUrl)
}
if (-not [string]::IsNullOrWhiteSpace($sourceCommit)) {
$publishArguments += @("--source-commit", $sourceCommit)
}
if (-not [string]::IsNullOrWhiteSpace($resolvedBaselineVersionOverride)) {
$publishArguments += @("--baseline-version", $resolvedBaselineVersionOverride)
}
if (-not [string]::IsNullOrWhiteSpace($resolvedBaselineRefOverride)) {
$publishArguments += @("--baseline-ref", $resolvedBaselineRefOverride)
}
& dotnet @publishArguments
if ($LASTEXITCODE -ne 0) {
throw "PLONDS publish command failed."
}
foreach ($config in $supportedPlatforms) {
$platform = $config.Platform
$state = $platformStates[$platform]
$artifactRoot = Join-Path $AppArtifactsRoot $config.ArtifactName
if (-not (Test-Path -LiteralPath $artifactRoot)) {
throw "App payload artifact root not found for ${platform}: $artifactRoot"
}
$currentAppDir = Resolve-AppDirectory -SearchRoot $artifactRoot -Version $Version
if ([string]::IsNullOrWhiteSpace($currentAppDir)) {
throw "Unable to locate app payload directory for $platform under $artifactRoot"
}
$distributionId = "plonds-$Version-$platform"
$manifestPath = Join-Path $publishedRoot "manifests/$distributionId/plonds-filemap.json"
$manifestSignaturePath = "$manifestPath.sig"
$distributionPath = Join-Path $publishedRoot "meta/distributions/$distributionId.json"
$latestPath = Join-Path $publishedRoot "meta/channels/$Channel/$platform/latest.json"
$payloadSnapshotPath = Join-Path $publishedRoot "payloads/$platform/$Version/app-payload.zip"
New-ZipFromDirectory -SourceDirectory $currentAppDir -DestinationPath $payloadSnapshotPath
$changeSummary = Get-FileMapChangeSummary -Path $manifestPath
$changeCount = $changeSummary.Add + $changeSummary.Replace + $changeSummary.Delete
$commitVerificationAdjusted = $false
if ($IncrementalStrategy -eq "commit-range" -and -not $state.CommitInfo.HasPotentialPayloadImpact -and $changeCount -gt 0) {
$commitVerificationAdjusted = $true
Write-Warning "Commit range for $platform predicted no payload impact, but payload diff found $changeCount changes. Keeping payload diff as source of truth."
}
$metadata = @{
baselineVersion = $state.BaselineVersion
baselineRef = $state.BaselineRef
baselineSource = $state.BaselineSource
sourceCommit = $sourceCommit
incrementalStrategy = $IncrementalStrategy
isFullPayloadRelease = $isFullPayloadRelease.ToString().ToLowerInvariant()
commitRangeStart = $state.CommitInfo.Start
commitRangeEnd = $state.CommitInfo.End
commitChangeCount = [string]$state.CommitInfo.ChangeCount
commitHasPotentialPayloadImpact = [string]$state.CommitInfo.HasPotentialPayloadImpact
commitRequiresComponentExpansion = [string]$state.CommitInfo.RequiresComponentExpansion
commitVerificationAdjusted = [string]$commitVerificationAdjusted
commitSamplePaths = $state.CommitInfo.SamplePaths
payloadSnapshotPath = "lanmountain/update/payloads/$platform/$Version/app-payload.zip"
installerMirrorMode = $installerMirrorMode
installerMirrorBaseUrl = $installerBaseUrl
}
Update-JsonMetadata -Path $manifestPath -Metadata $metadata
Update-JsonMetadata -Path $distributionPath -Metadata $metadata
& (Join-Path $PSScriptRoot "Sign-FileMap.ps1") `
-FilesJsonPath $manifestPath `
-PrivateKeyPath $PrivateKeyPath `
-OutputPath $manifestSignaturePath
if ($LASTEXITCODE -ne 0) {
throw "Failed to re-sign PLONDS manifest for $platform"
}
$legacyOutputDir = Join-Path $legacyRoot $platform
New-Item -ItemType Directory -Path $legacyOutputDir -Force | Out-Null
& (Join-Path $PSScriptRoot "Generate-DeltaPackage.ps1") `
-PreviousVersion $state.BaselineVersion `
-CurrentVersion $Version `
-PreviousDir $state.LegacyPreviousDir `
-CurrentDir $currentAppDir `
-OutputDir $legacyOutputDir
if ($LASTEXITCODE -ne 0) {
throw "Generate-DeltaPackage.ps1 failed for $platform"
}
$legacyManifestPath = Join-Path $legacyOutputDir "files.json"
$legacySignaturePath = Join-Path $legacyOutputDir "files.json.sig"
& (Join-Path $PSScriptRoot "Sign-FileMap.ps1") `
-FilesJsonPath $legacyManifestPath `
-PrivateKeyPath $PrivateKeyPath `
-OutputPath $legacySignaturePath
if ($LASTEXITCODE -ne 0) {
throw "Failed to sign legacy manifest for $platform"
}
Copy-Item -LiteralPath $manifestPath -Destination (Join-Path $releaseAssetsRoot "plonds-filemap-$platform.json") -Force
Copy-Item -LiteralPath $manifestSignaturePath -Destination (Join-Path $releaseAssetsRoot "plonds-filemap-$platform.json.sig") -Force
Copy-Item -LiteralPath $distributionPath -Destination (Join-Path $releaseAssetsRoot "plonds-distribution-$platform.json") -Force
Copy-Item -LiteralPath $latestPath -Destination (Join-Path $releaseAssetsRoot "plonds-latest-$platform.json") -Force
Copy-Item -LiteralPath $payloadSnapshotPath -Destination (Join-Path $releaseAssetsRoot "plonds-payload-$platform.zip") -Force
Copy-Item -LiteralPath $legacyManifestPath -Destination (Join-Path $releaseAssetsRoot "files-$platform.json") -Force
Copy-Item -LiteralPath $legacySignaturePath -Destination (Join-Path $releaseAssetsRoot "files-$platform.json.sig") -Force
Copy-Item -LiteralPath (Join-Path $legacyOutputDir "update.zip") -Destination (Join-Path $releaseAssetsRoot "update-$platform.zip") -Force
}
if (-not [string]::IsNullOrWhiteSpace($S3Endpoint) -and -not [string]::IsNullOrWhiteSpace($S3Bucket)) {
Upload-DirectoryToS3 -LocalRoot (Join-Path $publishedRoot "payloads") -RemotePrefix "lanmountain/update/payloads" -SkipExisting
Upload-DirectoryToS3 -LocalRoot (Join-Path $publishedRoot "repo") -RemotePrefix "lanmountain/update/repo" -SkipExisting
if ($mirrorInstallers) {
Upload-InstallerDirectoryToS3 -LocalRoot (Join-Path $publishedRoot "installers") -RemotePrefix "lanmountain/update/installers"
}
else {
Write-Host "Skipping blocking S3 installer mirror upload. Installer mirrors will resolve via $installerMirrorMode."
}
Upload-DirectoryToS3 -LocalRoot (Join-Path $publishedRoot "manifests") -RemotePrefix "lanmountain/update/manifests"
if ($uploadMetaToS3) {
Upload-DirectoryToS3 -LocalRoot (Join-Path $publishedRoot "meta") -RemotePrefix "lanmountain/update/meta"
}
else {
Write-Host "Deferring S3 meta upload until after GitHub Release is published."
}
}
Write-Host "PLONDS publish staging completed."
Write-Host "Published root: $publishedRoot"
Write-Host "Release assets: $releaseAssetsRoot"