Add package sign to pipeline #5

Merged
alex merged 4 commits from add-package-sign-to-pipeline into master 2026-08-13 11:55:21 +00:00
4 changed files with 393 additions and 70 deletions
Showing only changes of commit 1aa9d9b44e - Show all commits
+154 -23
View File
@@ -2,17 +2,17 @@
# packs the MSIX with the version taken from the tag — three numbers of the tag
# and a zero the Store keeps for itself.
#
# The package goes to the Store and nowhere else, so it leaves the run as an
# artifact: someone picks it up and uploads it to Partner Center, which puts its
# own signature on it. Nothing is signed here and nothing is attached to the
# release — a publicly trusted code signing certificate is not to be had, and an
# unsigned package would look like something to install and install nowhere.
# Gitea makes the release for the tag itself, and it carries the tag alone.
# The package is built twice, because the two places it goes to want different
# things of it. The Store gets a package with the identity reserved in Partner
# Center and no signature — Partner Center signs it there. The release gets a
# package signed here, with SSL.com's certificate the private key of which never
# leaves their HSM; Windows installs nothing else. The two only differ inside,
# so the Store one carries a suffix in its name and never leaves the artifacts.
#
# The same requirements to the runner as in pull-request.yml apply: Windows, the
# .NET 10 SDK and an interactive desktop session for the tests. makeappx comes
# with a NuGet package (Packaging\Tools\SdkTools.csproj), so the Windows SDK does
# not have to be installed.
# .NET 10 SDK and an interactive desktop session for the tests. makeappx and
# signtool come with a NuGet package (Packaging\Tools\SdkTools.csproj), so the
# Windows SDK does not have to be installed.
name: Release
on:
@@ -84,9 +84,24 @@ jobs:
# reserves the last one, so it carries nothing the tag could tell
"version=$($tag.Substring(1)).0" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8
# The installer answers to nobody about a fourth number and takes the
# tag as it is
"plain=$($tag.Substring(1))" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8
# The signing account is asked about before anything is built rather than
# at the step that needs it: a release without a signed package is not a
# release, and finding that out after the build and the tests costs the
# whole run
- name: Check the signing credentials
env:
ESIGNER_USERNAME: ${{ secrets.ESIGNER_USERNAME }}
ESIGNER_PASSWORD: ${{ secrets.ESIGNER_PASSWORD }}
ESIGNER_TOTP_SECRET: ${{ secrets.ESIGNER_TOTP_SECRET }}
run: |
$ErrorActionPreference = 'Stop'
$missing = @('ESIGNER_USERNAME', 'ESIGNER_PASSWORD', 'ESIGNER_TOTP_SECRET') |
Where-Object { -not (Get-Item "Env:$_" -ErrorAction SilentlyContinue).Value }
if ($missing) {
throw "The repository secrets $($missing -join ', ') are not set. They are the SSL.com account the package is signed with; the TOTP secret is the one eSigner hands out for automated signing, not a six-digit code."
}
- name: Show the toolchain
run: dotnet --info
@@ -108,7 +123,12 @@ jobs:
# The package comes out as Partner Center wants it — the Store puts its own
# signature on it. The identity comes from repository variables and falls
# back to the defaults of the script when a variable is not set.
- name: Pack the MSIX
#
# This one is picked up by hand and uploaded to Partner Center, so it goes
# no further than the artifacts of the run: attached to the release it
# would sit there as a package nobody can install, next to one that
# installs — telling the two apart is what the suffix in the name is for
- name: Pack the MSIX for the Store
env:
IDENTITY_NAME: ${{ vars.MSIX_IDENTITY_NAME }}
PUBLISHER: ${{ vars.MSIX_PUBLISHER }}
@@ -116,7 +136,11 @@ jobs:
run: |
$ErrorActionPreference = 'Stop'
$arguments = @{ Version = '${{ steps.version.outputs.version }}' }
$arguments = @{
Version = '${{ steps.version.outputs.version }}'
PackageSuffix = 'store'
OutputPath = 'artifacts/store'
}
# An empty variable is left out rather than passed on: the script has
# defaults of its own, and an empty string would wipe them
@@ -132,18 +156,123 @@ jobs:
./Packaging/build-msix.ps1 @arguments
# The other half of the release: the same application as an ordinary
# installer, for handing round outside the Store. Nobody signs it, so
# SmartScreen warns about it — see Packaging\installer.iss
- name: Build the installer
run: ./Packaging/build-installer.ps1 -Version ${{ steps.version.outputs.plain }}
- name: Keep the Store packages
uses: actions/upload-artifact@v4
with:
name: msix-store-${{ steps.version.outputs.version }}
path: artifacts/store/packages/
if-no-files-found: error
# The artifact is where the package waits to be uploaded to Partner Center
- name: Keep the package
# eSigner CKA is a key storage provider: it puts the certificate into the
# store of this user and answers signtool's requests for the private key
# over SSL.com's API, so the key itself never comes down to the runner.
# From signtool's side it looks like a certificate on a token, minus the
# token and minus the person who would plug it in.
#
# The TOTP secret is what stands in for that person: eSigner hands it out
# once, for automated signing, and the tool makes the codes out of it
# itself
- name: Load the signing certificate
id: certificate
env:
CKA_URL: https://github.com/SSLcom/eSignerCKA/releases/download/v1.0.6/SSL.COM-eSigner-CKA_1.0.6.zip
ESIGNER_USERNAME: ${{ secrets.ESIGNER_USERNAME }}
ESIGNER_PASSWORD: ${{ secrets.ESIGNER_PASSWORD }}
ESIGNER_TOTP_SECRET: ${{ secrets.ESIGNER_TOTP_SECRET }}
run: |
$ErrorActionPreference = 'Stop'
$temporary = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { $env:TEMP }
$archive = Join-Path $temporary 'eSignerCKA.zip'
$unpacked = Join-Path $temporary 'eSignerCKA'
# Everything of the adapter's own — the installation and the master
# key it keeps the account in — lives outside the workspace: the
# workspace is what gets packed and uploaded
$suite = Join-Path $env:USERPROFILE '.signingsuite'
$installation = Join-Path $suite 'eSignerCKA'
Remove-Item $unpacked -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Path $suite -Force | Out-Null
Invoke-WebRequest -Uri $env:CKA_URL -OutFile $archive
Expand-Archive -Path $archive -DestinationPath $unpacked -Force
$installer = Get-ChildItem $unpacked -Recurse -Filter '*.exe' | Select-Object -First 1
if (-not $installer) { throw "No installer inside the eSigner CKA archive at '$env:CKA_URL'." }
# /CURRENTUSER, so that the certificate lands in the store of the user
# the build runs as — the same one signtool then looks in
& $installer.FullName /CURRENTUSER /VERYSILENT /SUPPRESSMSGBOXES "/DIR=$installation" | Out-Null
$tool = Join-Path $installation 'eSignerCKATool.exe'
if (-not (Test-Path $tool)) { throw "eSigner CKA did not install: '$tool' is not there." }
& $tool config -mode product -user $env:ESIGNER_USERNAME -pass $env:ESIGNER_PASSWORD -totp $env:ESIGNER_TOTP_SECRET -key (Join-Path $suite 'master.key') -r
if ($LASTEXITCODE -ne 0) { throw "eSigner CKA turned down the account: the tool exited with code $LASTEXITCODE." }
# A run of the same runner could have left a certificate loaded from
# another account; unload says nothing when there is nothing to
# unload, and its exit code is of no interest for that reason
& $tool unload | Out-Null
& $tool load
if ($LASTEXITCODE -ne 0) { throw "eSigner CKA could not load the certificate: the tool exited with code $LASTEXITCODE." }
# An account may hold more than one certificate — a renewal leaves the
# old one behind — and the one that lives longest is the one to sign
# with: a signature made by a certificate about to expire is timestamped
# and stays good, but the next release would have to be made anyway
$certificate = Get-ChildItem Cert:\CurrentUser\My -CodeSigningCert |
Sort-Object NotAfter -Descending |
Select-Object -First 1
if (-not $certificate) {
throw 'eSigner CKA loaded nothing into the certificate store. Does the account hold a code signing certificate — a document signature is a different thing and cannot sign a package.'
}
Write-Host "Signing as $($certificate.Subject), good until $($certificate.NotAfter.ToString('yyyy-MM-dd'))."
"thumbprint=$($certificate.Thumbprint)" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8
"subject=$($certificate.Subject)" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8
# The package of the release carries the identity the app is known by and
# the publisher of the certificate — Windows works out the family name of
# a package from the two together, so an update replaces the installed
# version only while both stay as they were. The names have no suffix:
# that is what the app looks for when it checks for an update
- name: Pack and sign the MSIX for the release
env:
IDENTITY_NAME: ${{ vars.MSIX_IDENTITY_NAME }}
PUBLISHER: ${{ steps.certificate.outputs.subject }}
PUBLISHER_DISPLAY_NAME: ${{ vars.MSIX_PUBLISHER_DISPLAY_NAME }}
THUMBPRINT: ${{ steps.certificate.outputs.thumbprint }}
run: |
$ErrorActionPreference = 'Stop'
$arguments = @{
Version = '${{ steps.version.outputs.version }}'
OutputPath = 'artifacts/release'
Publisher = $env:PUBLISHER
CertificateThumbprint = $env:THUMBPRINT
}
$variables = @{
IdentityName = $env:IDENTITY_NAME
PublisherDisplayName = $env:PUBLISHER_DISPLAY_NAME
}
foreach ($name in $variables.Keys) {
if ($variables[$name]) { $arguments[$name] = $variables[$name] }
}
./Packaging/build-msix.ps1 @arguments
- name: Keep the signed packages
uses: actions/upload-artifact@v4
with:
name: msix-${{ steps.version.outputs.version }}
path: artifacts/packages/
path: artifacts/release/packages/
if-no-files-found: error
# The .wixpdb next to each installer is left out on purpose: it is of use
@@ -183,7 +312,9 @@ jobs:
$release = Invoke-RestMethod $api -Method Post -Headers $headers -ContentType 'application/json' -Body $body
}
foreach ($file in Get-ChildItem artifacts/installers -File -Filter *.msi) {
# Only the signed packages: the Store one stays in the artifacts of
# the run, where whoever uploads it to Partner Center picks it up
foreach ($file in Get-ChildItem artifacts/release/packages -File) {
# A tag can be pushed again after it was deleted; the old file of
# the same name is dropped, otherwise the upload is refused
$existing = $release.assets | Where-Object { $_.name -eq $file.Name }
+124 -12
View File
@@ -3,14 +3,15 @@
Builds the CursorLang MSIX package.
.DESCRIPTION
Neither Visual Studio nor the Windows SDK is needed: makeappx arrives as a
NuGet package (Tools\SdkTools.csproj) and the application is built by the
plain .NET SDK.
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 is handed to Partner Center as it comes out of here — the Store
puts its own signature on it and does the rest. Nothing is signed here: a
publicly trusted code signing certificate is not to be had, and the Store
asks for none.
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.
@@ -20,6 +21,17 @@
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
@@ -31,8 +43,14 @@
.EXAMPLE
# A build for the Store — the identity comes from Partner Center
pwsh -File Packaging\build-msix.ps1 -Version 1.0.1.0 `
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(
@@ -43,6 +61,18 @@ param(
[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
@@ -78,6 +108,26 @@ 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) {
# Checked before the build rather than after it: the build takes minutes, and
# this does not get any truer while it runs
@@ -155,11 +205,34 @@ function Get-SdkTool {
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
@@ -184,7 +257,8 @@ foreach ($installed in @(Get-AppxPackage -Name $IdentityName)) {
Remove-Item $layoutRoot -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Path $packagesPath -Force | Out-Null
Write-Host 'Building...' -ForegroundColor Cyan
$suffix = if ($PackageSuffix) { "-$PackageSuffix" } else { '' }
$built = @()
$layout = Join-Path $layoutRoot 'x64'
@@ -215,8 +289,41 @@ $manifest = (Get-Content $manifestTemplate -Raw).
Set-Content -Path (Join-Path $layout 'AppxManifest.xml') -Value $manifest -Encoding UTF8
$result = Join-Path $packagesPath "CursorLang-$Version-x64.msix"
Invoke-Tool -Path $makeappx -Arguments @('pack', '/o', '/d', $layout, '/p', $result)
$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...' -ForegroundColor Cyan
@@ -231,7 +338,12 @@ Write-Host 'Done.' -ForegroundColor Green
Write-Host " $result"
Write-Host ''
Write-Host ' This file is uploaded to Partner Center as it is.'
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 ''
+56 -18
View File
@@ -167,8 +167,27 @@ MSIX всегда выполняются в контексте вошедшег
## Обновления
Приложение обновляет Store, а само приложение об этом не заботится: раздела
обновлений в окне нет, запросов в сеть нет и кода для них тоже нет.
Проверка выполняется при открытии окна настроек, а не при включении машины:
фоновая половина в сеть больше не ходит вовсе, да и показать ответ ей нечем.
Настройка в окне так и написана.
Приложение ищет новые версии среди выпусков собственного репозитория. Выпуск
годится, если его тег — это просто версия (`v1.2.3` или `1.2.3`) и к нему
приложен пакет MSIX. Тег, в котором есть что-то ещё, — в том числе `v1.2.3-beta`
— пропускается: предварительную версию берут намеренно, приложение её не
предлагает.
Из приложенных файлов предпочитается `.msixbundle` — он несёт обе архитектуры.
Если его нет, берётся пакет, в имени которого стоит архитектура этой машины:
`CursorLang-1.2.3.0-x64.msix`. Такие имена даёт `build-msix.ps1`, так что выпуск
делается прикладыванием того, что он собрал.
Пакет скачивается во временную папку и передаётся установщику приложений
Windows: тот показывает издателя, спрашивает подтверждение и заменяет
установленную версию. Подпись проверяет Windows, поэтому приложенный к выпуску
пакет должен быть подписан — неподписанный установится только на машине в режиме
разработчика; пайплайн подписывает то, что прикладывает. Работающее приложение
до перезапуска продолжает жить на старых файлах.
Дело не во вкусе, а в цене подписи. MSIX Windows установит только тогда, когда
доверяет подписи на нём, а публично доверенный сертификат для подписи кода
@@ -227,11 +246,11 @@ dotnet test --collect:"XPlat Code Coverage" --settings coverage.runsettings
Пайплайны лежат в `.gitea/workflows` и работают на Gitea Actions. Запрос
на слияние в `master` собирается и проверяется тестами; по тегу вида `v1.2.3`
проект собирается, проходит тесты и пакуется в MSIX, который остаётся
в артефактах прогона. Номер версии берётся только из тега — тег любого другого
вида останавливает прогон в самом начале. Версия пакета получается `1.2.3.0`:
Store принимает четыре числа и последнее оставляет себе, так что тег на него не
влияет.
проект собирается, проходит тесты, пакуется в MSIX и выкладывается релизом
с приложенными подписанными пакетами. Номер версии берётся только из тега — тег
любого другого вида останавливает прогон в самом начале. Версия пакета получается
`1.2.3.0`: Store принимает четыре числа и последнее оставляет себе, так что тег
на него не влияет.
Обоим пайплайнам нужен runner под Windows с меткой `windows-x64`, на нём —
.NET 10 SDK и git-lfs. Выгрузка исходников тянет файлы LFS: без них иконка
@@ -241,18 +260,29 @@ Store принимает четыре числа и последнее оста
окно на переднем плане или каретку, — на runner’е, живущем службой в нулевом
сеансе, пропускают себя: показать окно там негде.
Пакет идёт в Store и больше никуда: он не подписан, подпись на него ставит сам
Partner Center. Поэтому прогон оставляет его в артефактах под именем
`msix-1.2.3.0`, откуда его забирают и загружают руками; к релизу не прикладывается
ничего — релиз по тегу Gitea заводит сама, и в нём один только тег. Неподписанный
пакет, висящий в релизе, выглядел бы как то, что можно установить, и не
устанавливался бы нигде — см. раздел об обновлениях.
Пакет собирается дважды: Store и релиз хотят от него разного.
Identity берётся из переменных репозитория, а если те не заданы — из значений по
умолчанию в скрипте: `MSIX_IDENTITY_NAME`, `MSIX_PUBLISHER`
и `MSIX_PUBLISHER_DISPLAY_NAME`. Вместе identity и publisher задают family name
пакета, поэтому от версии к версии оба должны оставаться прежними — иначе Store
примет следующую за другое приложение.
Тот, что для Store, несёт identity, зарезервированную в Partner Center, и не
подписан — подпись на него ставит сам Partner Center. Дальше артефактов прогона
он не уходит: его забирают и загружают руками. Что это он, видно по имени —
`CursorLang-1.2.3.0-store.msixbundle`. Identity берётся из переменных
репозитория, а если те не заданы — из значений по умолчанию в скрипте:
`MSIX_IDENTITY_NAME`, `MSIX_PUBLISHER` и `MSIX_PUBLISHER_DISPLAY_NAME`.
Тот, что приложен к релизу, пайплайн подписывает сертификатом от SSL.com:
закрытый ключ остаётся в их HSM и на runner не попадает — на нём ставится
eSigner CKA как поставщик хранилища ключей, и `signtool` спрашивает ключ у него
так же, как спросил бы у токена. Учётная запись — три секрета репозитория:
`ESIGNER_USERNAME`, `ESIGNER_PASSWORD` и `ESIGNER_TOTP_SECRET`; последний — тот
секрет, который eSigner выдаёт для автоматической подписи, а не код с телефона.
Проверяются они до сборки, а не на шаге подписи: релиз без подписанного пакета —
не релиз.
`Publisher` у него не `MSIX_PUBLISHER`, а subject этого сертификата, прочитанный
прямо в прогоне: пакет, называющий кого-то другого, Windows считает подделкой.
Вместе с identity этот subject задаёт family name пакета, поэтому от релиза к
релизу оба должны оставаться прежними — иначе обновление встанет рядом со старой
версией, а не заменит её.
## Сборка пакета MSIX
@@ -282,6 +312,14 @@ Partner Center как есть.
Здесь ничего не подписывается: подпись на пакет ставит сам Store, а для установки
на эту машину вместо пакета регистрируется layout — см. `-Install` ниже.
`-CertificateThumbprint` подписывает всё, что собрано, сертификатом с этим
отпечатком из личного хранилища текущего пользователя; `-Publisher` тогда должен
называть его subject — скрипт говорит об этом до сборки, а не после. Обычно
подписывает пайплайн, но то же работает и руками, как только eSigner CKA — или
сертификат любого другого рода — положит сертификат в хранилище.
`-PackageSuffix` дописывается в конец имён файлов: сборки для Store и для релиза
различаются тем, что внутри, и больше ничем.
Приложение поставляется с собственной копией .NET: Windows не включает .NET 10, а
MSIX не может установить среду выполнения как зависимость пакета.
+59 -17
View File
@@ -162,8 +162,27 @@ after a switch of the mode, and that is a question asked once.
## Updates
The Store updates the app, and the app itself does nothing about it: there is no
updates section in the window, no request to the network and no code for either.
The check runs when the settings window is opened, not when the machine is
switched on: the background half no longer goes to the network at all, and there
would be nothing in it to show the answer. The setting in the window says as much.
The app looks for new versions among the releases of its own repository. A
release counts when its tag is a plain version — `v1.2.3` or `1.2.3` — and an
MSIX package is attached to it. A tag with anything else in it, `v1.2.3-beta`
among them, is passed over: a pre-release version is asked for on purpose, not
offered by the app.
Out of the attached files the `.msixbundle` is preferred — it carries both
architectures. Failing that, the package whose name holds the architecture of
this machine is taken: `CursorLang-1.2.3.0-x64.msix`. Those are the names
`build-msix.ps1` produces, so a release is made by attaching what it built.
The package is downloaded to the temp folder and handed to the Windows app
installer: it shows the publisher, asks for a confirmation and replaces the
installed version. Windows checks the signature, so the package attached to a
release has to be signed — an unsigned one installs nowhere but a machine in
developer mode; the pipeline signs what it attaches. The running app keeps
working off the old files until it is restarted.
That is not a matter of taste but of what a signature costs. Windows installs an
MSIX only when it trusts the signature on it, and a publicly trusted code signing
@@ -222,10 +241,11 @@ running agent and fails if any part of the WPF renderer is in it.
The pipelines live in `.gitea/workflows` and run on Gitea Actions. A pull
request into `master` is built and tested; a tag of the form `v1.2.3` is built,
tested and packed into an MSIX, which is left in the artifacts of the run. The
version is taken from the tag alone — a tag shaped any other way stops the run
right at the start. The package version ends up as `1.2.3.0`: the Store takes
four numbers and keeps the last one for itself, so the tag has no say in it.
tested, packed into an MSIX and published as a release with the signed packages
attached. The version is taken from the tag alone — a tag shaped any other way
stops the run right at the start. The package version ends up as `1.2.3.0`: the
Store takes four numbers and keeps the last one for itself, so the tag has no
say in it.
Both pipelines ask for a Windows runner labelled `windows-x64` with the .NET 10
SDK and git-lfs on it. The checkout pulls LFS files: without them the icon is a
@@ -235,18 +255,31 @@ need a desktop of their own — the end-to-end ones, and those that ask for the
foreground window or the caret — skip themselves on a runner that lives as a
service in session 0, where there is no desktop to show a window on.
The package goes to the Store and nowhere else: it is unsigned, and Partner
Center puts its own signature on it. So the run leaves it in the artifacts under
the name `msix-1.2.3.0`, where whoever uploads it picks it up by hand; nothing
is attached to the release, which Gitea makes for the tag by itself and which
carries the tag alone. An unsigned package hanging off a release would look like
something to install and install nowhere — see the section on updates.
The package is built twice over, because the Store and a release want different
things of it.
The identity comes from repository variables and falls back to the defaults of
the script when unset: `MSIX_IDENTITY_NAME`, `MSIX_PUBLISHER` and
`MSIX_PUBLISHER_DISPLAY_NAME`. Together the identity and the publisher decide
the family name of the package, so both have to stay as they are from version to
version, or the Store takes the next one for a different app.
The Store one carries the identity reserved in Partner Center and no signature —
Partner Center signs it there. It never leaves the artifacts of the run: someone
picks it up and uploads it by hand. Its name says which one it is —
`CursorLang-1.2.3.0-store.msixbundle`. The identity comes from repository
variables and falls back to the defaults of the script when unset:
`MSIX_IDENTITY_NAME`, `MSIX_PUBLISHER` and `MSIX_PUBLISHER_DISPLAY_NAME`.
The one attached to the release is signed by the pipeline with a certificate
from SSL.com, whose private key stays in their HSM and never comes down to the
runner: eSigner CKA is installed on it as a key storage provider, and `signtool`
asks that for the key the way it would ask a token. The account is three
repository secrets — `ESIGNER_USERNAME`, `ESIGNER_PASSWORD` and
`ESIGNER_TOTP_SECRET`; the last one is the secret eSigner hands out for
automated signing, not a code read off a phone. They are checked before the
build rather than at the signing step: a release without a signed package is
not a release.
Its `Publisher` is not `MSIX_PUBLISHER` but the subject of that certificate,
read off it in the run — Windows takes a package naming anyone else for a
forgery. Together with the identity that subject decides the family name of the
package, so both have to stay as they are from release to release, or an update
installs beside the old version instead of replacing it.
## Building the MSIX package
@@ -276,6 +309,15 @@ sake of machines that run the x64 one under emulation anyway.
Nothing here is signed: the Store signs the package itself, and for installing it
on this machine the layout is registered instead — see `-Install` below.
`-CertificateThumbprint` signs everything the build produces, with the
certificate of that thumbprint out of the personal store of the current user;
`-Publisher` then has to name its subject, and the script says so before it
starts building rather than after. The pipeline is what normally signs, but the
same works by hand once eSigner CKA — or a certificate of any other kind — has
put a certificate into the store. `-PackageSuffix` goes at the end of the file
names: the builds for the Store and for a release differ in what is inside them
and in nothing else.
The app ships with its own copy of .NET: Windows does not include .NET 10, and
MSIX cannot install a runtime as a package dependency.