2026-04-21 16:12:47 +08:00
param (
2026-04-20 23:28:11 +08:00
[ 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 ) ]
2026-04-21 16:12:47 +08:00
[ 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 "
2026-04-20 23:28:11 +08:00
)
$ErrorActionPreference = " Stop "
2026-04-21 16:12:47 +08:00
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 "
}
2026-04-20 23:28:11 +08:00
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
}
2026-04-21 16:12:47 +08:00
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
}
}
2026-04-21 08:27:06 +08:00
function Invoke-AwsCommandIfPossible {
2026-04-20 23:28:11 +08:00
param (
[ Parameter ( Mandatory = $true ) ]
[ string[] ] $Arguments ,
[ Parameter ( Mandatory = $false ) ]
[ switch ] $IgnoreFailure
)
if ( [ string ] :: IsNullOrWhiteSpace ( $S3Endpoint ) -or [ string ] :: IsNullOrWhiteSpace ( $S3Bucket ) ) {
2026-04-21 16:12:47 +08:00
return $null
2026-04-20 23:28:11 +08:00
}
2026-04-21 08:20:41 +08:00
$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 ) {
2026-04-21 16:12:47 +08:00
return ( & aws @Arguments 2 > $null )
2026-04-21 08:20:41 +08:00
}
2026-04-21 16:12:47 +08:00
return ( & aws @Arguments )
2026-04-21 00:46:57 +08:00
}
2026-04-21 08:20:41 +08:00
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
}
2026-04-21 00:46:57 +08:00
}
2026-04-20 23:28:11 +08:00
}
2026-04-21 08:27:06 +08:00
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 )
}
2026-04-21 16:12:47 +08:00
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 {
2026-04-21 08:27:06 +08:00
param (
[ Parameter ( Mandatory = $true ) ]
2026-04-21 16:12:47 +08:00
[ string ] $Key ,
[ Parameter ( Mandatory = $true ) ]
[ string ] $DestinationPath
2026-04-21 08:27:06 +08:00
)
2026-04-21 16:12:47 +08:00
New-Item -ItemType Directory -Path ( [ System.IO.Path ] :: GetDirectoryName ( $DestinationPath ) ) -Force | Out-Null
2026-04-21 08:27:06 +08:00
2026-04-21 16:12:47 +08:00
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 ,
2026-04-21 08:27:06 +08:00
2026-04-21 16:12:47 +08:00
[ 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
2026-04-21 08:27:06 +08:00
}
2026-04-21 16:12:47 +08:00
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
}
2026-04-21 08:27:06 +08:00
2026-04-21 16:12:47 +08:00
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 = " "
2026-04-21 08:27:06 +08:00
}
2026-04-21 16:12:47 +08:00
}
2026-04-21 08:27:06 +08:00
2026-04-21 16:12:47 +08:00
$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
2026-04-21 08:27:06 +08:00
}
}
2026-04-21 16:12:47 +08:00
if ( $requiresExpansion -or $expansionExtensions -contains $extension . ToLowerInvariant ( ) ) {
$requiresExpansion = $true
2026-04-21 08:27:06 +08:00
}
2026-04-21 16:12:47 +08:00
}
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 ]
2026-04-21 08:27:06 +08:00
}
2026-04-21 16:12:47 +08:00
}
$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 ) ] + +
}
}
}
2026-04-21 08:27:06 +08:00
2026-04-21 16:12:47 +08:00
return $summary
2026-04-21 08:27:06 +08:00
}
function Upload-DirectoryToS3 {
param (
[ Parameter ( Mandatory = $true ) ]
[ string ] $LocalRoot ,
[ Parameter ( Mandatory = $true ) ]
[ string ] $RemotePrefix ,
[ Parameter ( Mandatory = $false ) ]
2026-04-21 16:12:47 +08:00
[ switch ] $SkipExisting
2026-04-21 08:27:06 +08:00
)
if ( -not ( Test-Path -LiteralPath $LocalRoot ) ) {
2026-04-21 16:12:47 +08:00
Write-Host " Skipping missing upload root: $LocalRoot "
return
2026-04-21 08:27:06 +08:00
}
$files = Get-ChildItem -LiteralPath $LocalRoot -Recurse -File | Sort-Object FullName
if ( $files . Count -eq 0 ) {
Write-Host " No files found under $LocalRoot ; skipping upload. "
2026-04-21 16:12:47 +08:00
return
2026-04-21 08:27:06 +08:00
}
$index = 0
foreach ( $file in $files ) {
$index + +
$relativePath = Get-RelativePath -Root $LocalRoot -Path $file . FullName
$key = Get-S3Key -Prefix $RemotePrefix -RelativePath $relativePath
2026-04-21 16:12:47 +08:00
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
}
2026-04-21 08:27:06 +08:00
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
2026-04-21 16:12:47 +08:00
) | Out-Null
if ( $LASTEXITCODE -ne 0 ) {
throw " Failed to upload $key "
2026-04-21 08:27:06 +08:00
}
}
}
2026-04-21 16:12:47 +08:00
function Upload-InstallerDirectoryToS3 {
2026-04-21 08:27:06 +08:00
param (
[ Parameter ( Mandatory = $true ) ]
2026-04-21 16:12:47 +08:00
[ string ] $LocalRoot ,
2026-04-21 08:27:06 +08:00
[ Parameter ( Mandatory = $true ) ]
2026-04-21 16:12:47 +08:00
[ string ] $RemotePrefix
2026-04-21 08:27:06 +08:00
)
2026-04-21 16:12:47 +08:00
if ( -not ( Test-Path -LiteralPath $LocalRoot ) ) {
Write-Host " Skipping missing installer upload root: $LocalRoot "
return
2026-04-21 08:27:06 +08:00
}
2026-04-21 16:12:47 +08:00
$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 = 64 MB
multipart_chunksize = 32 MB
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
}
2026-04-21 08:27:06 +08:00
}
2026-04-20 23:28:11 +08:00
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 "
2026-04-21 16:12:47 +08:00
$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 ( )
}
2026-04-20 23:28:11 +08:00
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
2026-04-21 16:12:47 +08:00
New-Item -ItemType Directory -Path $legacyRoot -Force | Out-Null
2026-04-20 23:28:11 +08:00
$repoBaseUrl = if ( [ string ] :: IsNullOrWhiteSpace ( $S3Endpoint ) -or [ string ] :: IsNullOrWhiteSpace ( $S3Bucket ) ) {
$null
}
else {
" $( $S3Endpoint . TrimEnd ( '/' ) ) / $S3Bucket /lanmountain/update/repo/sha256 "
}
2026-04-21 16:12:47 +08:00
$installerBaseUrl = if ( -not [ string ] :: IsNullOrWhiteSpace ( $gitHubReleaseBaseUrl ) ) {
$gitHubReleaseBaseUrl
}
elseif ( [ string ] :: IsNullOrWhiteSpace ( $S3Endpoint ) -or [ string ] :: IsNullOrWhiteSpace ( $S3Bucket ) ) {
2026-04-20 23:28:11 +08:00
$null
}
else {
" $( $S3Endpoint . TrimEnd ( '/' ) ) / $S3Bucket /lanmountain/update/installers "
}
2026-04-21 16:12:47 +08:00
$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 ( )
}
2026-04-20 23:28:11 +08:00
2026-04-21 16:12:47 +08:00
$platformStates = @ { }
2026-04-20 23:28:11 +08:00
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 "
2026-04-21 16:12:47 +08:00
$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 ) " }
}
}
2026-04-20 23:28:11 +08:00
}
2026-04-21 16:12:47 +08:00
$baselineSource = " none "
if ( $isFullPayloadRelease ) {
" 0.0.0 " | Set-Content -LiteralPath $baselineVersionPath -Encoding ascii
$baselineSource = " empty "
2026-04-20 23:28:11 +08:00
}
else {
2026-04-21 16:12:47 +08:00
$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
}
2026-04-20 23:28:11 +08:00
}
2026-04-21 08:39:07 +08:00
$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
}
2026-04-21 16:12:47 +08:00
$legacyPreviousDir = $snapshotRoot
2026-04-20 23:28:11 +08:00
}
else {
2026-04-21 16:12:47 +08:00
$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
}
2026-04-20 23:28:11 +08:00
}
2026-04-21 16:12:47 +08:00
$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
2026-04-20 23:28:11 +08:00
}
2026-04-21 16:12:47 +08:00
Write-Host " Prepared baseline for $platform => version= $( $platformStates [ $platform ] . BaselineVersion ) , source= $baselineSource , strategy= $IncrementalStrategy "
2026-04-20 23:28:11 +08:00
}
$publishArguments = @ (
" run " ,
" --project " , $toolProject ,
" -- " ,
" publish " ,
" --version " , $Version ,
" --app-artifacts-root " , $AppArtifactsRoot ,
" --installer-artifacts-root " , $InstallerArtifactsRoot ,
" --output-dir " , $publishedRoot ,
" --private-key " , $PrivateKeyPath ,
" --baseline-root " , $baselineRoot ,
2026-04-21 16:12:47 +08:00
" --channel " , $Channel ,
" --incremental-strategy " , $IncrementalStrategy ,
" --is-full-payload-release " , $isFullPayloadRelease . ToString ( ) . ToLowerInvariant ( )
2026-04-20 23:28:11 +08:00
)
if ( -not [ string ] :: IsNullOrWhiteSpace ( $repoBaseUrl ) ) {
$publishArguments + = @ ( " --repo-base-url " , $repoBaseUrl )
}
2026-04-21 16:12:47 +08:00
2026-04-20 23:28:11 +08:00
if ( -not [ string ] :: IsNullOrWhiteSpace ( $installerBaseUrl ) ) {
$publishArguments + = @ ( " --installer-base-url " , $installerBaseUrl )
}
2026-04-21 16:12:47 +08:00
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 )
}
2026-04-20 23:28:11 +08:00
& dotnet @publishArguments
if ( $LASTEXITCODE -ne 0 ) {
throw " PLONDS publish command failed. "
}
foreach ( $config in $supportedPlatforms ) {
$platform = $config . Platform
2026-04-21 16:12:47 +08:00
$state = $platformStates [ $platform ]
2026-04-20 23:28:11 +08:00
$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 "
2026-04-21 16:12:47 +08:00
$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
}
2026-04-20 23:28:11 +08:00
2026-04-21 16:12:47 +08:00
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
2026-04-20 23:28:11 +08:00
New-Item -ItemType Directory -Path $legacyOutputDir -Force | Out-Null
& ( Join-Path $PSScriptRoot " Generate-DeltaPackage.ps1 " ) `
2026-04-21 16:12:47 +08:00
-PreviousVersion $state . BaselineVersion `
2026-04-20 23:28:11 +08:00
-CurrentVersion $Version `
2026-04-21 16:12:47 +08:00
-PreviousDir $state . LegacyPreviousDir `
2026-04-20 23:28:11 +08:00
-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
2026-04-21 16:12:47 +08:00
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
2026-04-20 23:28:11 +08:00
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 ) ) {
2026-04-21 16:12:47 +08:00
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. "
2026-04-20 23:28:11 +08:00
}
}
Write-Host " PLONDS publish staging completed. "
Write-Host " Published root: $publishedRoot "
Write-Host " Release assets: $releaseAssetsRoot "