Files
cursor-lang/Packaging/build-msix.ps1
T
alex 460ce52017
Pull request / build (pull_request) Successful in 54s
modified release pipeline
2026-08-13 02:21:50 +05:00

369 lines
14 KiB
PowerShell

<#
.SYNOPSIS
Builds the CursorLang MSIX package.
.DESCRIPTION
Neither Visual Studio nor the Windows SDK is needed: makeappx and signtool
arrive as a NuGet package (Tools\SdkTools.csproj) and the application is
built by the plain .NET SDK.
The package comes out of here in one of two shapes. Left unsigned it goes to
Partner Center as it is — the Store puts its own signature on it and does the
rest. Signed with -CertificateThumbprint it is a package anyone can install
from a release, because Windows only takes a package whose signature it
trusts.
The application is published with its own copy of .NET: Windows does not
carry one, and MSIX cannot install the runtime as a package dependency.
.PARAMETER IdentityName
The identity of the package — from Partner Center, the "Product identity"
section. It is handed out there together with the reserved application name;
the default is only good enough for a check on your own machine.
.PARAMETER PackageSuffix
Goes at the end of the file names. The two builds of a release differ in the
identity inside them and in nothing a file listing shows, so the one meant
for the Store is told apart by a suffix of its own.
.PARAMETER CertificateThumbprint
Signs the packages with the certificate of this thumbprint from the personal
store of the current user. The private key is none of this script's business:
signtool asks the store for it, and behind a cloud certificate — eSigner CKA
among them — the store answers over the network.
.PARAMETER Install
Puts the package onto this machine to see it working. Developer mode has to
be on; a signature is not needed, because what gets registered is the layout
the package is made of rather than the package file.
.EXAMPLE
# A check on your own machine: your architecture alone
pwsh -File Packaging\build-msix.ps1 -Architectures x64
.EXAMPLE
# Build and install in one go, to click through the application
pwsh -File Packaging\build-msix.ps1 -Architectures x64 -Install
.EXAMPLE
# A build for the Store — the identity comes from Partner Center
pwsh -File Packaging\build-msix.ps1 -Version 1.0.1.0 -PackageSuffix store `
-IdentityName 12345AleksandrNeichev.CursorLang -Publisher "CN=ABCD1234-..."
.EXAMPLE
# A build for a release — the publisher is the subject of the certificate
pwsh -File Packaging\build-msix.ps1 -Version 1.0.1.0 `
-Publisher "CN=Aleksandr Neichev, O=Aleksandr Neichev, C=KZ" `
-CertificateThumbprint A1B2C3D4E5F60718293A4B5C6D7E8F9012345678
#>
[CmdletBinding()]
param(
# Four numbers, the last one has to be 0: that is what the Store requires
[string] $Version = '1.0.0.0',
[string] $IdentityName = 'CursorLang',
[string] $Publisher = 'CN=Aleksandr Neichev',
[string] $PublisherDisplayName = 'Aleksandr Neichev',
[ValidateSet('x64', 'arm64')]
[string[]] $Architectures = @('x64', 'arm64'),
[string] $PackageSuffix,
[string] $CertificateThumbprint,
# SSL.com's timestamp server, to go with the certificate the pipeline signs
# with. A signature without a timestamp is only good while the certificate
# is: the day it expires the package stops installing everywhere at once
[string] $TimestampUrl = 'http://ts.ssl.com',
[switch] $Install,
[string] $OutputPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$root = $PSScriptRoot
$repository = Split-Path -Parent $root
$agentProject = Join-Path $repository 'CursorLang.Agent\CursorLang.Agent.csproj'
$settingsProject = Join-Path $repository 'CursorLang.Settings\CursorLang.Settings.csproj'
$assets = Join-Path $root 'Assets'
$manifestTemplate = Join-Path $root 'AppxManifest.xml'
$toolsProject = Join-Path $root 'Tools\SdkTools.csproj'
if (-not $OutputPath) {
$OutputPath = Join-Path $repository 'artifacts'
} elseif (-not [System.IO.Path]::IsPathRooted($OutputPath)) {
# Windows names the folder a package was registered from in full, and the
# layout is looked for by that name below. A relative path would never be
# found there, and the registration of a build gone by would be left behind
$OutputPath = Join-Path (Get-Location).Path $OutputPath
}
$layoutRoot = Join-Path $OutputPath 'layout'
$packagesPath = Join-Path $OutputPath 'packages'
if ($Version -notmatch '^\d+\.\d+\.\d+\.0$') {
throw "The version '$Version' does not fit: the Store takes four numbers ending in zero, for example 1.0.0.0."
}
if (-not (Test-Path $assets)) {
throw "No logos found in '$assets'. Run Packaging\New-Assets.ps1 first."
}
if ($CertificateThumbprint) {
# Both things below are checked before the build for the same reason the
# ones under -Install are: the build takes minutes and neither answer
# changes while it runs
$certificate = Get-ChildItem "Cert:\CurrentUser\My\$CertificateThumbprint" -ErrorAction SilentlyContinue
if (-not $certificate) {
throw "No certificate with the thumbprint '$CertificateThumbprint' in the personal store of this user. A cloud certificate has to be loaded into the store first — eSigner CKA is what does that."
}
# The Publisher of a package is not a name of the publisher's choosing: it
# is the subject of the certificate the package is signed with, letter for
# letter. signtool turns down a package that claims any other, and it does
# so at the very end — after everything has already been built
if ($certificate.Subject -ne $Publisher) {
throw "The publisher '$Publisher' is not the subject of the certificate, which reads '$($certificate.Subject)'. A package carries the name of whoever signs it."
}
}
if ($Install) {
# Both things below are checked before the build rather than after it: the
# build takes minutes, and neither of them gets any truer while it runs
$developerMode = Get-ItemPropertyValue `
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock' `
-Name 'AllowDevelopmentWithoutDevLicense' -ErrorAction SilentlyContinue
if ($developerMode -ne 1) {
throw 'Installing needs developer mode: Settings - System - For developers - Developer mode. Without a signature Windows registers a package no other way.'
}
$machineArchitecture = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq 'Arm64') { 'arm64' } else { 'x64' }
if ($Architectures -notcontains $machineArchitecture) {
throw "This machine is $machineArchitecture, and that architecture is not being built. Add it to -Architectures, or drop -Install."
}
}
function Invoke-Tool {
<#
.SYNOPSIS
Runs a program and fails the build if it returned an error.
.DESCRIPTION
A wrapper of our own is needed because PowerShell does not treat the
failure of an external program as an error and quietly moves on.
#>
param(
[string] $Path,
[string[]] $Arguments
)
& $Path @Arguments
if ($LASTEXITCODE -ne 0) {
throw "'$([System.IO.Path]::GetFileName($Path))' exited with code $LASTEXITCODE."
}
}
function Get-SdkToolsPath {
<#
.SYNOPSIS
Returns the folder of the restored Windows SDK Build Tools package.
.DESCRIPTION
The path is asked of MSBuild rather than searched for in the package
cache: the project pins one version of the package, and MSBuild
names exactly it. A search would also find the versions left over
from other builds.
#>
$path = (& dotnet msbuild $toolsProject -getProperty:PkgMicrosoft_Windows_SDK_BuildTools -nologo) |
Where-Object { $_ -and $_.Trim() } |
Select-Object -Last 1
if ($LASTEXITCODE -ne 0 -or -not $path -or -not (Test-Path $path.Trim())) {
throw 'The Windows SDK Build Tools package folder could not be found. Did the restore go through?'
}
return $path.Trim()
}
function Get-SdkTool {
<#
.SYNOPSIS
Returns the path of a program from the Windows SDK Build Tools package.
#>
param(
[string] $Name,
[string] $PackagePath
)
# The bitness of the program has nothing to do with the bitness of the
# package being built: we take the one that runs on this machine
$host64 = if ([Environment]::Is64BitOperatingSystem) { 'x64' } else { 'x86' }
$tool = Get-ChildItem (Join-Path $PackagePath 'bin') -Recurse -Filter $Name -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -match "\\$host64\\" } |
Select-Object -First 1
if (-not $tool) {
throw "'$Name' was not found in the Windows SDK Build Tools package."
}
return $tool.FullName
}
function Invoke-Signing {
<#
.SYNOPSIS
Signs a package with the certificate the build was given.
#>
param(
[string] $Path,
[string] $SignTool
)
Write-Host "Signing $([System.IO.Path]::GetFileName($Path))..." -ForegroundColor Cyan
Invoke-Tool -Path $SignTool -Arguments @(
'sign',
'/fd', 'sha256',
'/tr', $TimestampUrl,
'/td', 'sha256',
'/sha1', $CertificateThumbprint,
$Path
)
}
Write-Host 'Fetching the Windows SDK programs...' -ForegroundColor Cyan
Invoke-Tool -Path 'dotnet' -Arguments @('restore', $toolsProject, '--nologo')
$sdkTools = Get-SdkToolsPath
$makeappx = Get-SdkTool -Name 'makeappx.exe' -PackagePath $sdkTools
$signtool = if ($CertificateThumbprint) { Get-SdkTool -Name 'signtool.exe' -PackagePath $sdkTools } else { $null }
# A package registered out of the layout runs straight from that folder, and
# the folder is about to be wiped. Left in place, the registration would point
# at files that are gone — an application that neither starts nor uninstalls
# the usual way. This happens whatever the build was asked for, so -Install has
# no say in it.
#
# Only a registration made from this very folder is removed. A copy installed
# from the Store answers to the same name and is none of our business
foreach ($installed in @(Get-AppxPackage -Name $IdentityName)) {
$location = $installed.InstallLocation
if (-not $location) { continue }
if (-not $location.TrimEnd('\').StartsWith($layoutRoot.TrimEnd('\'), [StringComparison]::OrdinalIgnoreCase)) {
continue
}
Write-Host "Removing the registered $($installed.PackageFullName)..." -ForegroundColor Cyan
Remove-AppxPackage -Package $installed.PackageFullName
}
Remove-Item $layoutRoot -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Path $packagesPath -Force | Out-Null
$suffix = if ($PackageSuffix) { "-$PackageSuffix" } else { '' }
$built = @()
foreach ($architecture in $Architectures) {
Write-Host "Building $architecture..." -ForegroundColor Cyan
$layout = Join-Path $layoutRoot $architecture
foreach ($half in @($agentProject, $settingsProject)) {
Invoke-Tool -Path 'dotnet' -Arguments @(
'publish', $half,
'--configuration', 'Release',
'--runtime', "win-$architecture",
'--self-contained', 'true',
"-p:Version=$($Version.Substring(0, $Version.LastIndexOf('.')))",
'--output', $layout,
'--nologo'
)
}
# Debug symbols have no place in the package: they take up room, the user
# has no use for them, and for crash reports the Store takes them separately
Get-ChildItem $layout -Recurse -Filter '*.pdb' | Remove-Item -Force
Copy-Item $assets -Destination (Join-Path $layout 'Assets') -Recurse -Force
$manifest = (Get-Content $manifestTemplate -Raw).
Replace('{IdentityName}', $IdentityName).
Replace('{Publisher}', $Publisher).
Replace('{PublisherDisplayName}', $PublisherDisplayName).
Replace('{Version}', $Version).
Replace('{Architecture}', $architecture)
Set-Content -Path (Join-Path $layout 'AppxManifest.xml') -Value $manifest -Encoding UTF8
$package = Join-Path $packagesPath "CursorLang-$Version-$architecture$suffix.msix"
Invoke-Tool -Path $makeappx -Arguments @('pack', '/o', '/d', $layout, '/p', $package)
$built += $package
}
$result = $built[0]
if ($built.Count -gt 1) {
Write-Host 'Bringing the architectures into one package...' -ForegroundColor Cyan
# makeappx bundle takes everything from a folder, so the separate packages
# are gathered into one of their own first — otherwise the results of
# earlier builds would end up in the bundle
$bundleInput = Join-Path $OutputPath 'bundle'
Remove-Item $bundleInput -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Path $bundleInput -Force | Out-Null
$built | ForEach-Object { Copy-Item $_ -Destination $bundleInput }
$result = Join-Path $packagesPath "CursorLang-$Version$suffix.msixbundle"
Invoke-Tool -Path $makeappx -Arguments @('bundle', '/o', '/d', $bundleInput, '/p', $result, '/bv', $Version)
Remove-Item $bundleInput -Recurse -Force
}
if ($CertificateThumbprint) {
# Signing comes after the bundle rather than before it: makeappx copies the
# packages into the bundle as they are, and a signature on what lies inside
# says nothing about the bundle around it. Windows asks the outer file, so
# every file that leaves here is signed on its own — each one of them is a
# package someone may install
foreach ($package in (@($built) + @($result) | Select-Object -Unique)) {
Invoke-Signing -Path $package -SignTool $signtool
}
}
if ($Install) {
Write-Host "Installing the $machineArchitecture build..." -ForegroundColor Cyan
# The layout is registered rather than the package file: the two hold the
# same thing, but a package file Windows only installs when it is signed
Add-AppxPackage -Register (Join-Path $layoutRoot "$machineArchitecture\AppxManifest.xml")
}
Write-Host ''
Write-Host 'Done.' -ForegroundColor Green
Write-Host " $result"
Write-Host ''
if ($CertificateThumbprint) {
Write-Host ' This file is signed: Windows installs it on any machine.'
} else {
Write-Host ' This file is uploaded to Partner Center as it is.'
}
if ($Install) {
Write-Host ''
Write-Host ' The application is installed and runs out of the layout folder, so'
Write-Host ' building again would pull the files out from under it. This script'
Write-Host ' takes care of that itself; to remove the application by hand:'
Write-Host " Remove-AppxPackage (Get-AppxPackage -Name $IdentityName).PackageFullName"
}