Files
moonwell-launcher/tool/deploy_launcher.ps1
T
sindoring f834c5fa86 Synchronize the client and select installed language packs (#3)
The launcher synchronizes the current native client, retains manifest-listed ruRU archives and launches the game with WOW_LOCALE=auto. Includes the prepared cleanup of obsolete Extentions directories in both client roots while preserving manifest-required files and unrelated user state.

Version: 1.0.7+10. Deployment now judges native OpenSSL commands by their exit codes while retaining key and signature verification.

Verified on Windows through FVM: full format/analyze, all 76 tests, production dry run, x64 runtime imports and DSA installer signature. The original dirty checkout was preserved. Publication follows the localized client release.
2026-10-10 16:50:35 +03:00

743 lines
24 KiB
PowerShell

<#
.SYNOPSIS
Builds, signs, verifies, and deploys MoonWell Launcher to production.
.DESCRIPTION
Builds the Windows application and Inno Setup installer, verifies the DSA
signing key pair, signs the installer, generates appcast.xml, uploads the
installer to Yandex Object Storage, and publishes AppCast through the MoonWell
service API. Secrets are read only from environment variables.
.PARAMETER ExpectedVersion
Fails unless the X.Y.Z part of pubspec.yaml matches this value.
.PARAMETER ReleaseNotes
Text written to the AppCast item description.
.PARAMETER EnvFile
Local dotenv file used to fill environment variables that are not already set.
.PARAMETER DryRun
Builds, signs, and verifies local artifacts without changing production.
.PARAMETER Force
Allows redeploying a version that is not newer than the public AppCast.
.PARAMETER SkipChecks
Skips pub get, formatting, static analysis, and tests.
.EXAMPLE
.\tool\deploy_launcher.ps1 -DryRun -ExpectedVersion 1.0.2 `
-ReleaseNotes "MoonWell Launcher improvements."
.EXAMPLE
.\tool\deploy_launcher.ps1 -ExpectedVersion 1.0.2 `
-ReleaseNotes "MoonWell Launcher improvements."
#>
#requires -Version 5.1
[CmdletBinding()]
param(
[string]$ExpectedVersion,
[string]$ReleaseNotes = 'MoonWell Launcher update.',
[string]$ApiBaseUrl = 'https://moon-well.online',
[string]$AppcastUrl = 'https://moon-well.online/appcast.xml',
[string]$S3Endpoint,
[string]$S3Bucket,
[string]$S3Key = 'moonwell_launcher_setup.exe',
[string]$EnvFile = '.env',
[string]$PrivateKeyPath = '.moonwell_signing\dsa_priv.pem',
[string]$PublicKeyPath = 'windows\runner\resources\dsa_pub.pem',
[string]$FlutterCommand = 'flutter',
[string]$DartCommand = 'dart',
[string]$InnoSetupCommand,
[string]$OpenSslCommand,
[string]$VcRuntimePath,
[switch]$SkipChecks,
[switch]$DryRun,
[switch]$Force
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$script:SparkleNamespace =
'http://www.andymatuschak.org/xml-namespaces/sparkle'
function Write-Step {
param([Parameter(Mandatory = $true)][string]$Message)
Write-Host "`n==> $Message" -ForegroundColor Cyan
}
function Resolve-RequiredCommand {
param(
[Parameter(Mandatory = $true)][string]$Command,
[Parameter(Mandatory = $true)][string]$Description
)
if (Test-Path -LiteralPath $Command -PathType Leaf) {
return (Resolve-Path -LiteralPath $Command).Path
}
$resolved = Get-Command $Command -ErrorAction SilentlyContinue
if ($null -eq $resolved) {
throw "$Description was not found: $Command"
}
return $resolved.Source
}
function Invoke-RequiredCommand {
param(
[Parameter(Mandatory = $true)][string]$Command,
[Parameter(Mandatory = $true)][string[]]$Arguments
)
# Windows PowerShell represents native stderr as error records even on exit 0.
# OpenSSL writes its normal key-reading notices there; the exit code is the verdict.
$previousPreference = $ErrorActionPreference
try {
$ErrorActionPreference = 'Continue'
& $Command @Arguments
$commandExitCode = $LASTEXITCODE
}
finally {
$ErrorActionPreference = $previousPreference
}
if ($commandExitCode -ne 0) {
throw "Command failed with exit code ${commandExitCode}: $Command"
}
}
function Get-FileSha256 {
param([Parameter(Mandatory = $true)][string]$Path)
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
}
function Import-DotEnv {
param([Parameter(Mandatory = $true)][string]$Path)
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
return
}
$allowedNames = @(
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
'AWS_DEFAULT_REGION',
'AWS_BUCKET',
'AWS_ENDPOINT',
'AWS_USE_PATH_STYLE_ENDPOINT',
'LAUNCHER_AUTH_KEY'
)
foreach ($line in Get-Content -LiteralPath $Path) {
$trimmed = $line.Trim()
if (
[string]::IsNullOrWhiteSpace($trimmed) -or
$trimmed.StartsWith('#')
) {
continue
}
$separator = $trimmed.IndexOf('=')
if ($separator -le 0) {
throw "Invalid dotenv line in ${Path}: $line"
}
$name = $trimmed.Substring(0, $separator).Trim()
if ($allowedNames -notcontains $name) {
continue
}
$value = $trimmed.Substring($separator + 1).Trim()
if (
$value.Length -ge 2 -and
(
($value.StartsWith('"') -and $value.EndsWith('"')) -or
($value.StartsWith("'") -and $value.EndsWith("'"))
)
) {
$value = $value.Substring(1, $value.Length - 2)
}
$existing = [Environment]::GetEnvironmentVariable($name)
if ([string]::IsNullOrWhiteSpace($existing)) {
[Environment]::SetEnvironmentVariable(
$name,
$value,
[EnvironmentVariableTarget]::Process
)
}
}
}
function Get-AppcastVersion {
param([Parameter(Mandatory = $true)][string]$Path)
[xml]$xml = Get-Content -LiteralPath $Path -Raw -Encoding UTF8
$enclosure = $xml.rss.channel.item.enclosure
if ($null -eq $enclosure) {
throw "AppCast does not contain channel/item/enclosure: $Path"
}
return $enclosure.GetAttribute('version', $script:SparkleNamespace)
}
function Write-Appcast {
param(
[Parameter(Mandatory = $true)][string]$Path,
[Parameter(Mandatory = $true)][string]$Version,
[Parameter(Mandatory = $true)][string]$BuildVersion,
[Parameter(Mandatory = $true)][string]$Notes,
[Parameter(Mandatory = $true)][string]$InstallerUrl,
[Parameter(Mandatory = $true)][string]$Signature,
[Parameter(Mandatory = $true)][long]$Length
)
$settings = New-Object System.Xml.XmlWriterSettings
$settings.Encoding = New-Object System.Text.UTF8Encoding($false)
$settings.Indent = $true
$settings.IndentChars = ' '
$settings.NewLineChars = "`n"
$settings.NewLineHandling = [System.Xml.NewLineHandling]::Replace
$writer = [System.Xml.XmlWriter]::Create($Path, $settings)
try {
$writer.WriteStartDocument()
$writer.WriteStartElement('rss')
$writer.WriteAttributeString('version', '2.0')
$writer.WriteAttributeString(
'xmlns',
'sparkle',
'http://www.w3.org/2000/xmlns/',
$script:SparkleNamespace
)
$writer.WriteStartElement('channel')
$writer.WriteElementString('title', 'MoonWell Launcher')
$writer.WriteElementString(
'description',
'MoonWell Launcher updates'
)
$writer.WriteElementString('language', 'ru')
$writer.WriteStartElement('item')
$writer.WriteElementString(
'title',
"MoonWell Launcher $Version"
)
$writer.WriteElementString('description', $Notes)
$writer.WriteElementString(
'pubDate',
[DateTimeOffset]::Now.ToString(
'ddd, dd MMM yyyy HH:mm:ss zzz',
[Globalization.CultureInfo]::InvariantCulture
)
)
$writer.WriteStartElement('enclosure')
$writer.WriteAttributeString('url', $InstallerUrl)
$writer.WriteAttributeString(
'sparkle',
'version',
$script:SparkleNamespace,
$BuildVersion
)
$writer.WriteAttributeString(
'sparkle',
'shortVersionString',
$script:SparkleNamespace,
$Version
)
$writer.WriteAttributeString(
'sparkle',
'os',
$script:SparkleNamespace,
'windows'
)
$writer.WriteAttributeString(
'sparkle',
'dsaSignature',
$script:SparkleNamespace,
$Signature
)
$writer.WriteAttributeString('length', $Length.ToString())
$writer.WriteAttributeString(
'type',
'application/octet-stream'
)
$writer.WriteEndElement()
$writer.WriteEndElement()
$writer.WriteEndElement()
$writer.WriteEndElement()
$writer.WriteEndDocument()
}
finally {
$writer.Dispose()
}
}
function Assert-SigningKeyPair {
param(
[Parameter(Mandatory = $true)][string]$OpenSsl,
[Parameter(Mandatory = $true)][string]$PrivateKey,
[Parameter(Mandatory = $true)][string]$PublicKey,
[Parameter(Mandatory = $true)][string]$TemporaryPublicKey
)
Invoke-RequiredCommand -Command $OpenSsl -Arguments @(
'dsa',
'-in',
$PrivateKey,
'-pubout',
'-out',
$TemporaryPublicKey
)
$expected = (
Get-Content -LiteralPath $PublicKey -Raw
) -replace '\s', ''
$actual = (
Get-Content -LiteralPath $TemporaryPublicKey -Raw
) -replace '\s', ''
if ($expected -cne $actual) {
throw 'The private DSA key does not match the launcher public key.'
}
}
function Assert-InstallerSignature {
param(
[Parameter(Mandatory = $true)][string]$OpenSsl,
[Parameter(Mandatory = $true)][string]$InstallerPath,
[Parameter(Mandatory = $true)][string]$PublicKey,
[Parameter(Mandatory = $true)][string]$Signature,
[Parameter(Mandatory = $true)][string]$TemporaryDirectory
)
$signaturePath = Join-Path $TemporaryDirectory 'installer.sig'
$firstDigestPath = Join-Path $TemporaryDirectory 'installer.sha1'
[IO.File]::WriteAllBytes(
$signaturePath,
[Convert]::FromBase64String($Signature)
)
$stream = [IO.File]::OpenRead($InstallerPath)
try {
$sha1 = [Security.Cryptography.SHA1]::Create()
try {
[IO.File]::WriteAllBytes(
$firstDigestPath,
$sha1.ComputeHash($stream)
)
}
finally {
$sha1.Dispose()
}
}
finally {
$stream.Dispose()
}
Invoke-RequiredCommand -Command $OpenSsl -Arguments @(
'dgst',
'-sha1',
'-verify',
$PublicKey,
'-signature',
$signaturePath,
$firstDigestPath
)
}
$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$previousLocation = Get-Location
try {
Set-Location $repositoryRoot
$resolvedEnvFile = if ([IO.Path]::IsPathRooted($EnvFile)) {
$EnvFile
}
else {
Join-Path $repositoryRoot $EnvFile
}
Import-DotEnv -Path $resolvedEnvFile
if ([string]::IsNullOrWhiteSpace($S3Endpoint)) {
$S3Endpoint = $env:AWS_ENDPOINT
}
if ([string]::IsNullOrWhiteSpace($S3Endpoint)) {
$S3Endpoint = 'https://storage.yandexcloud.net'
}
if ([string]::IsNullOrWhiteSpace($S3Bucket)) {
$S3Bucket = $env:AWS_BUCKET
}
if ([string]::IsNullOrWhiteSpace($S3Bucket)) {
$S3Bucket = 'warcraft-client'
}
foreach ($url in @($ApiBaseUrl, $AppcastUrl, $S3Endpoint)) {
$uri = $null
if (
-not [Uri]::TryCreate(
$url,
[UriKind]::Absolute,
[ref]$uri
) -or
$uri.Scheme -ne 'https'
) {
throw "Production URL must be absolute HTTPS: $url"
}
}
$python = $null
if (-not $DryRun) {
foreach ($name in @(
'AWS_ACCESS_KEY_ID',
'AWS_SECRET_ACCESS_KEY',
'LAUNCHER_AUTH_KEY'
)) {
$value = [Environment]::GetEnvironmentVariable($name)
if ([string]::IsNullOrWhiteSpace($value)) {
throw "Required environment variable $name is not set."
}
}
$python = Resolve-RequiredCommand `
-Command 'python' `
-Description 'Python'
Invoke-RequiredCommand -Command $python -Arguments @(
'-c',
'import boto3'
)
}
$versionMatch = [regex]::Match(
(Get-Content -LiteralPath 'pubspec.yaml' -Raw),
'(?m)^version:\s*(\d+\.\d+\.\d+)\+(\d+)\s*$'
)
if (-not $versionMatch.Success) {
throw 'The pubspec.yaml version must use the X.Y.Z+N format.'
}
$version = $versionMatch.Groups[1].Value
$buildNumber = $versionMatch.Groups[2].Value
if (
-not [string]::IsNullOrWhiteSpace($ExpectedVersion) -and
$ExpectedVersion -ne $version
) {
throw "Expected version $ExpectedVersion, pubspec.yaml contains $version."
}
$privateKey = (Resolve-Path -LiteralPath $PrivateKeyPath).Path
$publicKey = (Resolve-Path -LiteralPath $PublicKeyPath).Path
$flutter = Resolve-RequiredCommand `
-Command $FlutterCommand `
-Description 'Flutter SDK'
$dart = Resolve-RequiredCommand `
-Command $DartCommand `
-Description 'Dart SDK'
if ([string]::IsNullOrWhiteSpace($InnoSetupCommand)) {
$InnoSetupCommand =
'C:\Program Files (x86)\Inno Setup 6\ISCC.exe'
}
$innoSetup = Resolve-RequiredCommand `
-Command $InnoSetupCommand `
-Description 'Inno Setup Compiler'
if ([string]::IsNullOrWhiteSpace($OpenSslCommand)) {
$gitOpenSsl = 'C:\Program Files\Git\usr\bin\openssl.exe'
$OpenSslCommand = if (Test-Path -LiteralPath $gitOpenSsl) {
$gitOpenSsl
}
else {
'openssl'
}
}
$openSsl = Resolve-RequiredCommand `
-Command $OpenSslCommand `
-Description 'OpenSSL'
$temporaryDirectory = Join-Path $repositoryRoot 'build\launcher_release'
New-Item -ItemType Directory -Force -Path $temporaryDirectory | Out-Null
Write-Step "Checking DSA keys for version $version+$buildNumber"
Assert-SigningKeyPair `
-OpenSsl $openSsl `
-PrivateKey $privateKey `
-PublicKey $publicKey `
-TemporaryPublicKey (
Join-Path $temporaryDirectory 'derived_dsa_pub.pem'
)
if (-not $DryRun -and -not $Force) {
Write-Step 'Checking the published version'
try {
$publishedPath = Join-Path $temporaryDirectory 'current_appcast.xml'
Invoke-WebRequest `
-Uri $AppcastUrl `
-UseBasicParsing `
-OutFile $publishedPath
$publishedVersion = Get-AppcastVersion -Path $publishedPath
$publishedReleaseVersion = $publishedVersion.Split('+')[0]
if (
-not [string]::IsNullOrWhiteSpace($publishedVersion) -and
[version]$version -le [version]$publishedReleaseVersion
) {
throw (
"Version $version is not newer than published version " +
"$publishedVersion. Increase X.Y.Z or use -Force."
)
}
}
catch {
if ($_.Exception.Message -like 'Version *') {
throw
}
Write-Warning (
'Could not determine the currently published version: ' +
$_.Exception.Message
)
}
}
if (-not $SkipChecks) {
Write-Step 'Dependencies, formatting, analysis, and tests'
Invoke-RequiredCommand -Command $flutter -Arguments @('pub', 'get')
Invoke-RequiredCommand -Command $dart -Arguments @(
'format',
'--output=none',
'--set-exit-if-changed',
'lib',
'test'
)
Invoke-RequiredCommand -Command $flutter -Arguments @(
'analyze',
'lib',
'test'
)
Invoke-RequiredCommand -Command $flutter -Arguments @('test')
}
Write-Step 'Building the Windows production application'
Invoke-RequiredCommand -Command $flutter -Arguments @(
'build',
'windows',
'--release',
"--dart-define=MOONWELL_API_BASE_URL=$ApiBaseUrl",
"--dart-define=MOONWELL_APPCAST_URL=$AppcastUrl"
)
Write-Step 'Bundling the x64 Visual C++ runtime'
if ([string]::IsNullOrWhiteSpace($VcRuntimePath)) {
$vswherePath = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe'
$vsInstall = (& $vswherePath -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath).Trim()
if ($LASTEXITCODE -ne 0 -or -not $vsInstall) {
throw 'Visual Studio C++ runtime was not found; pass -VcRuntimePath.'
}
$redistVersion = Get-ChildItem -LiteralPath (Join-Path $vsInstall 'VC\Redist\MSVC') -Directory |
Where-Object { $_.Name -match '^\d+\.\d+\.\d+$' } |
Sort-Object { [version]$_.Name } -Descending | Select-Object -First 1
$VcRuntimePath = Join-Path $redistVersion.FullName 'x64\Microsoft.VC143.CRT'
}
foreach ($requiredDll in @('msvcp140.dll', 'vcruntime140.dll', 'vcruntime140_1.dll')) {
if (-not (Test-Path -LiteralPath (Join-Path $VcRuntimePath $requiredDll) -PathType Leaf)) {
throw "The x64 Visual C++ redistributable $requiredDll was not found."
}
}
Get-ChildItem -LiteralPath $VcRuntimePath -Filter '*.dll' -File | ForEach-Object {
Copy-Item -LiteralPath $_.FullName -Destination 'build\windows\x64\runner\Release'
}
Write-Step 'Building the Inno Setup installer'
Invoke-RequiredCommand -Command $innoSetup -Arguments @(
"/DMyAppVersion=$version",
'installer\moonwell_launcher.iss'
)
$installerPath = Join-Path (
Join-Path $repositoryRoot 'build\installer'
) "moonwell_launcher_${version}_windows_setup.exe"
if (-not (Test-Path -LiteralPath $installerPath -PathType Leaf)) {
throw "The installer was not created: $installerPath"
}
Write-Step 'Signing the installer with DSA'
$gitOpenSslDirectory = Split-Path $openSsl -Parent
$previousPath = $env:PATH
try {
$env:PATH = "$gitOpenSslDirectory;$env:PATH"
$signOutput = Invoke-RequiredCommand -Command $dart -Arguments @(
'run', 'auto_updater:sign_update', $installerPath, $privateKey
) 2>&1
}
finally {
$env:PATH = $previousPath
}
$signatureMatch = [regex]::Match(
($signOutput -join "`n"),
'sparkle:dsaSignature="([^"]+)"',
[Text.RegularExpressions.RegexOptions]::Singleline
)
if (-not $signatureMatch.Success) {
throw 'The signing tool did not return sparkle:dsaSignature.'
}
$signature = $signatureMatch.Groups[1].Value -replace '\s', ''
[void][Convert]::FromBase64String($signature)
$installer = Get-Item -LiteralPath $installerPath
$installerSha256 = Get-FileSha256 -Path $installerPath
Assert-InstallerSignature `
-OpenSsl $openSsl `
-InstallerPath $installerPath `
-PublicKey $publicKey `
-Signature $signature `
-TemporaryDirectory $temporaryDirectory
$installerUrl = (
$S3Endpoint.TrimEnd('/') +
'/' +
$S3Bucket +
'/' +
$S3Key
)
$appcastBuildVersion = "$version+$buildNumber"
$appcastPath = if ($DryRun) {
Join-Path $temporaryDirectory 'appcast.xml'
}
else {
Join-Path $repositoryRoot 'appcast.xml'
}
Write-Step 'Generating appcast.xml'
Write-Appcast `
-Path $appcastPath `
-Version $version `
-BuildVersion $appcastBuildVersion `
-Notes $ReleaseNotes `
-InstallerUrl $installerUrl `
-Signature $signature `
-Length $installer.Length
[xml]$generatedAppcast = Get-Content `
-LiteralPath $appcastPath `
-Raw `
-Encoding UTF8
if ((Get-AppcastVersion -Path $appcastPath) -ne $appcastBuildVersion) {
throw 'The generated AppCast contains the wrong version.'
}
if ($DryRun) {
Write-Step 'Dry run complete - production was not changed'
Write-Host "Version: $version+$buildNumber"
Write-Host "Installer: $installerPath"
Write-Host "Size: $($installer.Length)"
Write-Host "SHA-256: $installerSha256"
Write-Host "AppCast: $appcastPath"
return
}
Write-Step "Uploading $S3Bucket/$S3Key"
$env:MOONWELL_RELEASE_FILE = $installerPath
$env:MOONWELL_RELEASE_ENDPOINT = $S3Endpoint
$env:MOONWELL_RELEASE_BUCKET = $S3Bucket
$env:MOONWELL_RELEASE_KEY = $S3Key
$env:MOONWELL_RELEASE_VERSION = $version
$env:MOONWELL_RELEASE_SHA256 = $installerSha256
$uploadHelper = Join-Path `
$repositoryRoot `
'tool\upload_launcher_release.py'
if (-not (Test-Path -LiteralPath $uploadHelper -PathType Leaf)) {
throw "S3 upload helper was not found: $uploadHelper"
}
try {
Invoke-RequiredCommand -Command $python -Arguments @($uploadHelper)
}
finally {
Remove-Item Env:MOONWELL_RELEASE_FILE -ErrorAction SilentlyContinue
Remove-Item Env:MOONWELL_RELEASE_ENDPOINT -ErrorAction SilentlyContinue
Remove-Item Env:MOONWELL_RELEASE_BUCKET -ErrorAction SilentlyContinue
Remove-Item Env:MOONWELL_RELEASE_KEY -ErrorAction SilentlyContinue
Remove-Item Env:MOONWELL_RELEASE_VERSION -ErrorAction SilentlyContinue
Remove-Item Env:MOONWELL_RELEASE_SHA256 -ErrorAction SilentlyContinue
}
Write-Step 'Verifying the uploaded installer'
$downloadedInstaller = Join-Path `
$temporaryDirectory `
'published_installer.exe'
Invoke-WebRequest `
-Uri $installerUrl `
-UseBasicParsing `
-OutFile $downloadedInstaller
$publishedSha256 = Get-FileSha256 -Path $downloadedInstaller
if ($publishedSha256 -ne $installerSha256) {
throw (
'The uploaded installer SHA-256 does not match the local file: ' +
"$publishedSha256"
)
}
Write-Step 'Publishing AppCast'
$publishResponse = Invoke-WebRequest `
-Uri "$($ApiBaseUrl.TrimEnd('/'))/api/service/update-appcast" `
-Method Post `
-Headers @{
Auth = $env:LAUNCHER_AUTH_KEY
Accept = 'application/json'
} `
-ContentType 'application/xml' `
-Body ([IO.File]::ReadAllBytes($appcastPath)) `
-UseBasicParsing
if ($publishResponse.StatusCode -lt 200 -or $publishResponse.StatusCode -ge 300) {
throw "AppCast publication returned HTTP $($publishResponse.StatusCode)."
}
Write-Step 'Verifying the published AppCast'
$publishedAppcastPath = Join-Path `
$temporaryDirectory `
'published_appcast.xml'
$publicResponse = Invoke-WebRequest `
-Uri $AppcastUrl `
-UseBasicParsing `
-OutFile $publishedAppcastPath `
-PassThru
if ($publicResponse.StatusCode -ne 200) {
throw "The public AppCast returned HTTP $($publicResponse.StatusCode)."
}
if (
(Get-FileSha256 -Path $publishedAppcastPath) -ne
(Get-FileSha256 -Path $appcastPath)
) {
throw 'The published AppCast does not match the local file.'
}
[xml]$publishedAppcast = Get-Content `
-LiteralPath $publishedAppcastPath `
-Raw `
-Encoding UTF8
$publishedEnclosure = $publishedAppcast.rss.channel.item.enclosure
if (
$publishedEnclosure.GetAttribute(
'version',
$script:SparkleNamespace
) -ne $appcastBuildVersion -or
[long]$publishedEnclosure.length -ne $installer.Length -or
$publishedEnclosure.url -ne $installerUrl
) {
throw 'The published AppCast contains incorrect release data.'
}
Write-Step 'Production deployment complete'
Write-Host "Version: $version+$buildNumber"
Write-Host "Installer: $installerUrl"
Write-Host "SHA-256: $installerSha256"
Write-Host "AppCast: $AppcastUrl"
}
finally {
Set-Location $previousLocation
}