f834c5fa86
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.
743 lines
24 KiB
PowerShell
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
|
|
}
|