499 lines
21 KiB
PowerShell
499 lines
21 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
Creates deterministic isolated Campaign LA A/B update feeds.
|
|
|
|
.DESCRIPTION
|
|
Packages caller-supplied published client and launcher roots for win-x64
|
|
and linux-x64, adds a deterministic release marker, calculates the exact
|
|
LA10 SHA-256/size manifest fields, and emits a loopback-only static server
|
|
plus a local A/B selector. It never downloads, connects, edits a payload
|
|
source, or writes outside -OutputDirectory.
|
|
#>
|
|
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$OutputDirectory,
|
|
[Parameter(Mandatory = $true)][string]$ClientWinX64DirectoryA,
|
|
[Parameter(Mandatory = $true)][string]$LauncherWinX64DirectoryA,
|
|
[Parameter(Mandatory = $true)][string]$ClientLinuxX64DirectoryA,
|
|
[Parameter(Mandatory = $true)][string]$LauncherLinuxX64DirectoryA,
|
|
[Parameter(Mandatory = $true)][string]$ClientWinX64DirectoryB,
|
|
[Parameter(Mandatory = $true)][string]$LauncherWinX64DirectoryB,
|
|
[Parameter(Mandatory = $true)][string]$ClientLinuxX64DirectoryB,
|
|
[Parameter(Mandatory = $true)][string]$LauncherLinuxX64DirectoryB,
|
|
[string]$VersionA = '1.0.1-la11.a',
|
|
[string]$VersionB = '1.0.1-la11.b',
|
|
[string]$MinimumLauncherVersion = '1.0.0',
|
|
[int]$Port = 43119,
|
|
[switch]$DryRun
|
|
)
|
|
|
|
Set-StrictMode -Version Latest
|
|
$ErrorActionPreference = 'Stop'
|
|
if ($PSVersionTable.PSVersion.Major -lt 7) {
|
|
throw 'Campaign LA update fixture creation requires PowerShell 7 or newer.'
|
|
}
|
|
if (-not [IO.Path]::IsPathFullyQualified($OutputDirectory)) {
|
|
throw '-OutputDirectory must be absolute.'
|
|
}
|
|
$OutputDirectory = [IO.Path]::TrimEndingDirectorySeparator(
|
|
[IO.Path]::GetFullPath($OutputDirectory))
|
|
|
|
function Assert-NoReparseAncestry([string]$Path, [string]$Description) {
|
|
$cursor = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Path))
|
|
while (-not (Test-Path -LiteralPath $cursor)) {
|
|
$parent = [IO.Path]::GetDirectoryName($cursor)
|
|
if ([string]::IsNullOrEmpty($parent) -or $parent -ceq $cursor) { break }
|
|
$cursor = $parent
|
|
}
|
|
while (-not [string]::IsNullOrEmpty($cursor)) {
|
|
$item = Get-Item -LiteralPath $cursor -Force
|
|
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
|
throw "$Description has a reparse point in its ancestry."
|
|
}
|
|
$parent = [IO.Directory]::GetParent($cursor)
|
|
if ($null -eq $parent) { break }
|
|
$cursor = $parent.FullName
|
|
}
|
|
}
|
|
|
|
function Test-SameOrDescendant([string]$Path, [string]$Ancestor) {
|
|
$comparison = if ($IsWindows) {
|
|
[StringComparison]::OrdinalIgnoreCase
|
|
} else { [StringComparison]::Ordinal }
|
|
if ([string]::Equals($Path, $Ancestor, $comparison)) { return $true }
|
|
$prefix = $Ancestor + [IO.Path]::DirectorySeparatorChar
|
|
return $Path.StartsWith($prefix, $comparison)
|
|
}
|
|
|
|
Assert-NoReparseAncestry $OutputDirectory 'Output directory'
|
|
if ($Port -lt 1024 -or $Port -gt 65535) { throw '-Port must be 1024..65535.' }
|
|
$semver = '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$'
|
|
if ($VersionA -notmatch $semver -or $VersionB -notmatch $semver -or
|
|
$MinimumLauncherVersion -notmatch $semver -or $VersionA -ceq $VersionB) {
|
|
throw 'VersionA, VersionB, and MinimumLauncherVersion must be SemVer 2.0; A and B must differ.'
|
|
}
|
|
$parsedVersionA = [semver]$VersionA
|
|
$parsedVersionB = [semver]$VersionB
|
|
$parsedMinimumLauncherVersion = [semver]$MinimumLauncherVersion
|
|
if ($parsedVersionB.CompareTo($parsedVersionA) -le 0) {
|
|
throw 'VersionB must be newer than VersionA.'
|
|
}
|
|
if ($parsedMinimumLauncherVersion.CompareTo($parsedVersionA) -gt 0) {
|
|
throw 'MinimumLauncherVersion must not be newer than VersionA.'
|
|
}
|
|
|
|
$sources = [ordered]@{
|
|
'A-client-win-x64' = $ClientWinX64DirectoryA
|
|
'A-launcher-win-x64' = $LauncherWinX64DirectoryA
|
|
'A-client-linux-x64' = $ClientLinuxX64DirectoryA
|
|
'A-launcher-linux-x64' = $LauncherLinuxX64DirectoryA
|
|
'B-client-win-x64' = $ClientWinX64DirectoryB
|
|
'B-launcher-win-x64' = $LauncherWinX64DirectoryB
|
|
'B-client-linux-x64' = $ClientLinuxX64DirectoryB
|
|
'B-launcher-linux-x64' = $LauncherLinuxX64DirectoryB
|
|
}
|
|
foreach ($key in @($sources.Keys)) {
|
|
$source = [string]$sources[$key]
|
|
if (-not [IO.Path]::IsPathFullyQualified($source)) {
|
|
throw "Payload source '$key' must be absolute."
|
|
}
|
|
$source = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($source))
|
|
$sources[$key] = $source
|
|
if (-not $DryRun -and -not (Test-Path -LiteralPath $source -PathType Container)) {
|
|
throw "Payload source '$key' does not exist: $source"
|
|
}
|
|
Assert-NoReparseAncestry $source "Payload source '$key'"
|
|
if ((Test-SameOrDescendant $OutputDirectory $source) -or
|
|
(Test-SameOrDescendant $source $OutputDirectory)) {
|
|
throw "Output directory and payload source '$key' must not overlap."
|
|
}
|
|
}
|
|
|
|
function Require-PayloadFile([string]$Key, [string]$Name) {
|
|
if ($DryRun) { return }
|
|
if (-not (Test-Path -LiteralPath (Join-Path $sources[$Key] $Name) -PathType Leaf)) {
|
|
throw "Payload source '$Key' is missing root file '$Name'."
|
|
}
|
|
}
|
|
foreach ($release in @('A', 'B')) {
|
|
Require-PayloadFile "$release-client-win-x64" 'AcDream.App.exe'
|
|
Require-PayloadFile "$release-client-win-x64" 'acdream-headless.exe'
|
|
Require-PayloadFile "$release-launcher-win-x64" 'acdream-launcher.exe'
|
|
Require-PayloadFile "$release-launcher-win-x64" 'acdream-bake.exe'
|
|
Require-PayloadFile "$release-client-linux-x64" 'AcDream.App'
|
|
Require-PayloadFile "$release-client-linux-x64" 'acdream-headless'
|
|
Require-PayloadFile "$release-launcher-linux-x64" 'acdream-launcher'
|
|
Require-PayloadFile "$release-launcher-linux-x64" 'acdream-bake'
|
|
}
|
|
|
|
if (Test-Path -LiteralPath $OutputDirectory) {
|
|
if (@(Get-ChildItem -LiteralPath $OutputDirectory -Force).Count -gt 0) {
|
|
throw '-OutputDirectory must not already contain files.'
|
|
}
|
|
}
|
|
else { $null = New-Item -ItemType Directory -Path $OutputDirectory }
|
|
Assert-NoReparseAncestry $OutputDirectory 'Output directory'
|
|
|
|
if ($DryRun) {
|
|
$plan = [ordered]@{
|
|
schemaVersion = 1
|
|
kind = 'campaign-la-update-fixture-plan'
|
|
outputDirectory = $OutputDirectory
|
|
versions = @($VersionA, $VersionB)
|
|
minimumLauncherVersion = $MinimumLauncherVersion
|
|
port = $Port
|
|
sources = $sources
|
|
writesOutsideOutputDirectory = $false
|
|
externalNetwork = $false
|
|
}
|
|
$plan | ConvertTo-Json -Depth 5 |
|
|
Set-Content -LiteralPath (Join-Path $OutputDirectory 'dry-run.json') -Encoding utf8NoBOM
|
|
Write-Host "Campaign LA update fixture dry run: $OutputDirectory"
|
|
return
|
|
}
|
|
|
|
Add-Type -AssemblyName System.IO.Compression
|
|
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
|
$fixedTimestamp = [DateTimeOffset]::new(2000, 1, 1, 0, 0, 0, [TimeSpan]::Zero)
|
|
|
|
function Get-LittleEndianUInt16([byte[]]$Bytes, [int]$Offset) {
|
|
return [int]$Bytes[$Offset] -bor ([int]$Bytes[$Offset + 1] -shl 8)
|
|
}
|
|
|
|
function Get-LittleEndianUInt32([byte[]]$Bytes, [int]$Offset) {
|
|
return [uint32]([uint32]$Bytes[$Offset] -bor
|
|
([uint32]$Bytes[$Offset + 1] -shl 8) -bor
|
|
([uint32]$Bytes[$Offset + 2] -shl 16) -bor
|
|
([uint32]$Bytes[$Offset + 3] -shl 24))
|
|
}
|
|
|
|
function Set-DeterministicZipHostPlatform([string]$Path) {
|
|
[byte[]]$bytes = [IO.File]::ReadAllBytes($Path)
|
|
$minimumEocdSize = 22
|
|
if ($bytes.Length -lt $minimumEocdSize) {
|
|
throw "Generated ZIP is too short: $Path"
|
|
}
|
|
|
|
$eocd = -1
|
|
$minimumOffset = [Math]::Max(0, $bytes.Length - 65557)
|
|
for ($offset = $bytes.Length - $minimumEocdSize; $offset -ge $minimumOffset; $offset--) {
|
|
if ((Get-LittleEndianUInt32 $bytes $offset) -eq 0x06054b50) {
|
|
$commentLength = Get-LittleEndianUInt16 $bytes ($offset + 20)
|
|
if ($offset + $minimumEocdSize + $commentLength -eq $bytes.Length) {
|
|
$eocd = $offset
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if ($eocd -lt 0) { throw "Generated ZIP has no valid end record: $Path" }
|
|
if ((Get-LittleEndianUInt16 $bytes ($eocd + 4)) -ne 0 -or
|
|
(Get-LittleEndianUInt16 $bytes ($eocd + 6)) -ne 0) {
|
|
throw "Generated ZIP unexpectedly spans multiple disks: $Path"
|
|
}
|
|
|
|
$entriesOnDisk = Get-LittleEndianUInt16 $bytes ($eocd + 8)
|
|
$entryCount = Get-LittleEndianUInt16 $bytes ($eocd + 10)
|
|
if ($entriesOnDisk -ne $entryCount) {
|
|
throw "Generated ZIP central-directory count is inconsistent: $Path"
|
|
}
|
|
$centralSize = Get-LittleEndianUInt32 $bytes ($eocd + 12)
|
|
$centralOffset = Get-LittleEndianUInt32 $bytes ($eocd + 16)
|
|
if ([uint64]$centralOffset + [uint64]$centralSize -ne [uint64]$eocd) {
|
|
throw "Generated ZIP central-directory bounds are inconsistent: $Path"
|
|
}
|
|
|
|
[uint64]$cursor = $centralOffset
|
|
for ($index = 0; $index -lt $entryCount; $index++) {
|
|
if ($cursor + 46 -gt $eocd -or
|
|
(Get-LittleEndianUInt32 $bytes ([int]$cursor)) -ne 0x02014b50) {
|
|
throw "Generated ZIP central-directory entry is invalid: $Path"
|
|
}
|
|
# ZipArchive intentionally stamps the creating host (FAT on Windows,
|
|
# Unix on Linux) in the upper byte of "version made by". Normalize it
|
|
# to FAT; permissions are already explicit in ExternalAttributes.
|
|
$bytes[[int]$cursor + 5] = 0
|
|
$nameLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 28)
|
|
$extraLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 30)
|
|
$commentLength = Get-LittleEndianUInt16 $bytes ([int]$cursor + 32)
|
|
$cursor += 46 + $nameLength + $extraLength + $commentLength
|
|
}
|
|
if ($cursor -ne $eocd) {
|
|
throw "Generated ZIP central-directory length is inconsistent: $Path"
|
|
}
|
|
[IO.File]::WriteAllBytes($Path, $bytes)
|
|
}
|
|
|
|
function New-DeterministicZip(
|
|
[string]$SourceDirectory,
|
|
[string]$Destination,
|
|
[string]$ReleaseLabel,
|
|
[string]$PayloadKind,
|
|
[string]$Rid) {
|
|
$destinationDirectory = Split-Path -Parent $Destination
|
|
$null = New-Item -ItemType Directory -Force -Path $destinationDirectory
|
|
$stream = [IO.FileStream]::new(
|
|
$Destination,
|
|
[IO.FileMode]::CreateNew,
|
|
[IO.FileAccess]::ReadWrite,
|
|
[IO.FileShare]::None)
|
|
try {
|
|
$archive = [IO.Compression.ZipArchive]::new(
|
|
$stream,
|
|
[IO.Compression.ZipArchiveMode]::Create,
|
|
$true,
|
|
[Text.Encoding]::UTF8)
|
|
try {
|
|
$allEntries = @(Get-ChildItem -LiteralPath $SourceDirectory -Force -Recurse)
|
|
foreach ($item in $allEntries) {
|
|
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
|
throw "Payload contains a reparse point: $($item.FullName)"
|
|
}
|
|
}
|
|
[string[]]$files = @($allEntries |
|
|
Where-Object { -not $_.PSIsContainer } |
|
|
ForEach-Object {
|
|
[IO.Path]::GetRelativePath(
|
|
$SourceDirectory,
|
|
$_.FullName).Replace('\', '/')
|
|
})
|
|
[Array]::Sort($files, [StringComparer]::Ordinal)
|
|
$caseFolded = [Collections.Generic.HashSet[string]]::new(
|
|
[StringComparer]::OrdinalIgnoreCase)
|
|
foreach ($relative in $files) {
|
|
if ($relative.StartsWith('../', [StringComparison]::Ordinal) -or
|
|
[IO.Path]::IsPathRooted($relative) -or
|
|
-not $caseFolded.Add($relative)) {
|
|
throw "Payload path escaped its root: $relative"
|
|
}
|
|
$file = Get-Item -LiteralPath (
|
|
Join-Path $SourceDirectory $relative.Replace('/', [IO.Path]::DirectorySeparatorChar))
|
|
$entry = $archive.CreateEntry($relative, [IO.Compression.CompressionLevel]::Optimal)
|
|
$entry.LastWriteTime = $fixedTimestamp
|
|
$executable = $relative -ceq 'AcDream.App' -or
|
|
$relative -ceq 'acdream-headless' -or
|
|
$relative -ceq 'acdream-launcher' -or
|
|
$relative -ceq 'acdream-bake' -or
|
|
$relative.EndsWith('.sh', [StringComparison]::Ordinal)
|
|
$mode = if ($executable) { 0x81ED } else { 0x81A4 }
|
|
$entry.ExternalAttributes = $mode -shl 16
|
|
$input = [IO.File]::OpenRead($file.FullName)
|
|
$output = $entry.Open()
|
|
try { $input.CopyTo($output) }
|
|
finally { $output.Dispose(); $input.Dispose() }
|
|
}
|
|
$marker = $archive.CreateEntry(
|
|
'campaign-la-fixture-release.txt',
|
|
[IO.Compression.CompressionLevel]::Optimal)
|
|
$marker.LastWriteTime = $fixedTimestamp
|
|
$marker.ExternalAttributes = 0x81A4 -shl 16
|
|
$writer = [IO.StreamWriter]::new(
|
|
$marker.Open(),
|
|
[Text.UTF8Encoding]::new($false))
|
|
try {
|
|
$writer.NewLine = "`n"
|
|
$writer.Write("release=$ReleaseLabel`npayload=$PayloadKind`nrid=$Rid`n")
|
|
}
|
|
finally { $writer.Dispose() }
|
|
}
|
|
finally { $archive.Dispose() }
|
|
}
|
|
finally { $stream.Dispose() }
|
|
Set-DeterministicZipHostPlatform $Destination
|
|
}
|
|
|
|
function Get-Artifact([string]$Path, [string]$Url) {
|
|
$item = Get-Item -LiteralPath $Path
|
|
return [ordered]@{
|
|
url = $Url
|
|
sha256 = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
|
|
size = $item.Length
|
|
}
|
|
}
|
|
|
|
$releaseDefinitions = @(
|
|
[pscustomobject]@{ Label = 'A'; Version = $VersionA },
|
|
[pscustomobject]@{ Label = 'B'; Version = $VersionB }
|
|
)
|
|
foreach ($release in $releaseDefinitions) {
|
|
$releaseRoot = Join-Path $OutputDirectory $release.Label
|
|
foreach ($rid in @('win-x64', 'linux-x64')) {
|
|
New-DeterministicZip `
|
|
$sources["$($release.Label)-client-$rid"] `
|
|
(Join-Path $releaseRoot "client-$rid.zip") `
|
|
$release.Label 'client' $rid
|
|
New-DeterministicZip `
|
|
$sources["$($release.Label)-launcher-$rid"] `
|
|
(Join-Path $releaseRoot "launcher-$rid.zip") `
|
|
$release.Label 'launcher' $rid
|
|
}
|
|
$baseUri = "http://127.0.0.1:$Port/$($release.Label)"
|
|
$manifest = [ordered]@{
|
|
schemaVersion = 1
|
|
version = $release.Version
|
|
minimumLauncherVersion = $MinimumLauncherVersion
|
|
clients = [ordered]@{
|
|
'win-x64' = Get-Artifact `
|
|
(Join-Path $releaseRoot 'client-win-x64.zip') `
|
|
"$baseUri/client-win-x64.zip"
|
|
'linux-x64' = Get-Artifact `
|
|
(Join-Path $releaseRoot 'client-linux-x64.zip') `
|
|
"$baseUri/client-linux-x64.zip"
|
|
}
|
|
launchers = [ordered]@{
|
|
'win-x64' = Get-Artifact `
|
|
(Join-Path $releaseRoot 'launcher-win-x64.zip') `
|
|
"$baseUri/launcher-win-x64.zip"
|
|
'linux-x64' = Get-Artifact `
|
|
(Join-Path $releaseRoot 'launcher-linux-x64.zip') `
|
|
"$baseUri/launcher-linux-x64.zip"
|
|
}
|
|
}
|
|
[IO.File]::WriteAllText(
|
|
(Join-Path $releaseRoot 'manifest.json'),
|
|
($manifest | ConvertTo-Json -Depth 8 -Compress),
|
|
[Text.UTF8Encoding]::new($false))
|
|
}
|
|
[IO.File]::WriteAllText(
|
|
(Join-Path $OutputDirectory 'active-release.txt'),
|
|
'A',
|
|
[Text.Encoding]::ASCII)
|
|
|
|
$server = @'
|
|
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Root,
|
|
[Parameter(Mandatory = $true)][int]$Port,
|
|
[ValidateRange(0, 1000000)][int]$MaximumRequests = 0
|
|
)
|
|
Set-StrictMode -Version Latest
|
|
$ErrorActionPreference = 'Stop'
|
|
$expectedRoot = [IO.Path]::TrimEndingDirectorySeparator(
|
|
[IO.Path]::GetFullPath($PSScriptRoot))
|
|
$Root = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Root))
|
|
$pathComparison = if ($IsWindows) {
|
|
[StringComparison]::OrdinalIgnoreCase
|
|
} else { [StringComparison]::Ordinal }
|
|
if (-not [string]::Equals($Root, $expectedRoot, $pathComparison)) {
|
|
throw '-Root must be the directory containing serve-fixture.ps1.'
|
|
}
|
|
$prefix = "http://127.0.0.1:$Port/"
|
|
$listener = [Net.HttpListener]::new()
|
|
$listener.Prefixes.Add($prefix)
|
|
$listener.Start()
|
|
Write-Host "Campaign LA fixture listening on $prefix"
|
|
$servedRequests = 0
|
|
try {
|
|
while ($listener.IsListening) {
|
|
$context = $listener.GetContext()
|
|
try {
|
|
if ($context.Request.HttpMethod -cne 'GET') {
|
|
$context.Response.StatusCode = 405
|
|
continue
|
|
}
|
|
$relative = [Uri]::UnescapeDataString($context.Request.Url.AbsolutePath.TrimStart('/'))
|
|
if ($relative -ceq 'manifest.json') {
|
|
$active = (Get-Content -LiteralPath (Join-Path $Root 'active-release.txt') -Raw).Trim()
|
|
if ($active -notin @('A', 'B')) { throw 'active-release.txt must contain A or B.' }
|
|
$relative = "$active/manifest.json"
|
|
}
|
|
if ([string]::IsNullOrWhiteSpace($relative) -or $relative.Contains('..')) {
|
|
$context.Response.StatusCode = 404
|
|
continue
|
|
}
|
|
$path = [IO.Path]::GetFullPath((Join-Path $Root $relative))
|
|
if (-not $path.StartsWith($Root + [IO.Path]::DirectorySeparatorChar, [StringComparison]::Ordinal) -or
|
|
-not (Test-Path -LiteralPath $path -PathType Leaf)) {
|
|
$context.Response.StatusCode = 404
|
|
continue
|
|
}
|
|
$context.Response.ContentType = if ($path.EndsWith('.json', [StringComparison]::Ordinal)) {
|
|
'application/json'
|
|
} else { 'application/zip' }
|
|
$context.Response.StatusCode = 200
|
|
$context.Response.Headers['Cache-Control'] = 'no-store'
|
|
$context.Response.ContentLength64 = (Get-Item -LiteralPath $path).Length
|
|
$input = [IO.File]::OpenRead($path)
|
|
try { $input.CopyTo($context.Response.OutputStream) }
|
|
finally { $input.Dispose() }
|
|
}
|
|
catch {
|
|
$context.Response.StatusCode = 500
|
|
Write-Error $_
|
|
}
|
|
finally {
|
|
$context.Response.Close()
|
|
$servedRequests++
|
|
}
|
|
if ($MaximumRequests -gt 0 -and $servedRequests -ge $MaximumRequests) {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
finally { $listener.Close() }
|
|
'@
|
|
[IO.File]::WriteAllText(
|
|
(Join-Path $OutputDirectory 'serve-fixture.ps1'),
|
|
$server.Replace("`r`n", "`n"),
|
|
[Text.UTF8Encoding]::new($false))
|
|
|
|
$selector = @'
|
|
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Mandatory = $true)][ValidateSet('A', 'B')][string]$Release,
|
|
[string]$Root = $PSScriptRoot
|
|
)
|
|
Set-StrictMode -Version Latest
|
|
$expectedRoot = [IO.Path]::TrimEndingDirectorySeparator(
|
|
[IO.Path]::GetFullPath($PSScriptRoot))
|
|
$Root = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Root))
|
|
$pathComparison = if ($IsWindows) {
|
|
[StringComparison]::OrdinalIgnoreCase
|
|
} else { [StringComparison]::Ordinal }
|
|
if (-not [string]::Equals($Root, $expectedRoot, $pathComparison)) {
|
|
throw '-Root must be the directory containing set-active-release.ps1.'
|
|
}
|
|
$path = Join-Path $Root 'active-release.txt'
|
|
$temporary = "$path.$([Guid]::NewGuid().ToString('N')).tmp"
|
|
try {
|
|
[IO.File]::WriteAllText($temporary, $Release, [Text.Encoding]::ASCII)
|
|
[IO.File]::Move($temporary, $path, $true)
|
|
}
|
|
finally {
|
|
if ([IO.File]::Exists($temporary)) { [IO.File]::Delete($temporary) }
|
|
}
|
|
Write-Host "Campaign LA fixture active release: $Release"
|
|
'@
|
|
[IO.File]::WriteAllText(
|
|
(Join-Path $OutputDirectory 'set-active-release.ps1'),
|
|
$selector.Replace("`r`n", "`n"),
|
|
[Text.UTF8Encoding]::new($false))
|
|
|
|
$inventoryPaths = [string[]]@(Get-ChildItem -LiteralPath $OutputDirectory -File -Recurse |
|
|
Where-Object { $_.Name -ne 'fixture-report.json' } |
|
|
ForEach-Object {
|
|
[IO.Path]::GetRelativePath($OutputDirectory, $_.FullName).Replace('\', '/')
|
|
})
|
|
[Array]::Sort($inventoryPaths, [StringComparer]::Ordinal)
|
|
$inventory = @($inventoryPaths | ForEach-Object {
|
|
$fullPath = Join-Path $OutputDirectory $_.Replace('/', [IO.Path]::DirectorySeparatorChar)
|
|
$item = Get-Item -LiteralPath $fullPath
|
|
[ordered]@{
|
|
path = $_
|
|
size = $item.Length
|
|
sha256 = (Get-FileHash -LiteralPath $fullPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
|
}
|
|
})
|
|
$report = [ordered]@{
|
|
schemaVersion = 1
|
|
kind = 'campaign-la-update-fixture'
|
|
versions = [ordered]@{ A = $VersionA; B = $VersionB }
|
|
minimumLauncherVersion = $MinimumLauncherVersion
|
|
manifestUri = "http://127.0.0.1:$Port/manifest.json"
|
|
loopbackOnly = $true
|
|
initialRelease = 'A'
|
|
sourceDirectories = $sources
|
|
artifacts = $inventory
|
|
}
|
|
$report | ConvertTo-Json -Depth 8 |
|
|
Set-Content -LiteralPath (Join-Path $OutputDirectory 'fixture-report.json') -Encoding utf8NoBOM
|
|
Write-Host "Campaign LA update fixture: $OutputDirectory"
|