From 460ce520179d034c6dfe3684ad864cb998ae66b1 Mon Sep 17 00:00:00 2001 From: Aleksandr Neychev Date: Thu, 13 Aug 2026 02:21:50 +0500 Subject: [PATCH] modified release pipeline --- .gitea/workflows/release.yml | 165 +++++++++++++++++- CursorLang.Agent/CursorLang.Agent.csproj | 2 +- CursorLang.Core/CursorLang.Core.csproj | 2 +- .../CursorLang.Settings.csproj | 2 +- Packaging/build-msix.ps1 | 122 +++++++++++-- README.RU.md | 47 +++-- README.md | 47 ++++- 7 files changed, 345 insertions(+), 42 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 752ab25..4c42923 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -2,10 +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 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: @@ -77,6 +84,25 @@ 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 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 @@ -97,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 }} @@ -105,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 @@ -121,11 +156,123 @@ jobs: ./Packaging/build-msix.ps1 @arguments - - name: Keep the packages + - 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 + + # 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 # Gitea creates a release of its own for a pushed tag, so the release is @@ -153,7 +300,9 @@ jobs: $release = Invoke-RestMethod $api -Method Post -Headers $headers -ContentType 'application/json' -Body $body } - foreach ($file in Get-ChildItem artifacts/packages -File) { + # 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 } diff --git a/CursorLang.Agent/CursorLang.Agent.csproj b/CursorLang.Agent/CursorLang.Agent.csproj index 6658901..8f2b472 100644 --- a/CursorLang.Agent/CursorLang.Agent.csproj +++ b/CursorLang.Agent/CursorLang.Agent.csproj @@ -22,7 +22,7 @@ 1.0.0.0 1.0.0.0 CursorLang - Aleksandr Neychev + Aleksandr Neichev Shows the keyboard layout at the cursor Copyright (c) 2026 diff --git a/CursorLang.Core/CursorLang.Core.csproj b/CursorLang.Core/CursorLang.Core.csproj index 74e20bc..c0c40b9 100644 --- a/CursorLang.Core/CursorLang.Core.csproj +++ b/CursorLang.Core/CursorLang.Core.csproj @@ -16,7 +16,7 @@ 1.0.0.0 1.0.0.0 CursorLang - Aleksandr Neychev + Aleksandr Neichev Shared part of CursorLang: models, settings, layout tracking, updates Copyright (c) 2026 diff --git a/CursorLang.Settings/CursorLang.Settings.csproj b/CursorLang.Settings/CursorLang.Settings.csproj index 409a83c..4f82c24 100644 --- a/CursorLang.Settings/CursorLang.Settings.csproj +++ b/CursorLang.Settings/CursorLang.Settings.csproj @@ -24,7 +24,7 @@ 1.0.0.0 1.0.0.0 CursorLang - Aleksandr Neychev + Aleksandr Neichev Settings window of CursorLang Copyright (c) 2026 diff --git a/Packaging/build-msix.ps1 b/Packaging/build-msix.ps1 index 80a7b19..048f430 100644 --- a/Packaging/build-msix.ps1 +++ b/Packaging/build-msix.ps1 @@ -1,14 +1,17 @@ <# .SYNOPSIS - Builds the CursorLang MSIX package for the Microsoft Store. + 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 - is where it gets everything else done to it. + 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. @@ -18,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 @@ -33,8 +47,14 @@ .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 ` - -IdentityName 12345AleksandrNeychev.CursorLang -Publisher "CN=ABCD1234-..." + -Publisher "CN=Aleksandr Neichev, O=Aleksandr Neichev, C=KZ" ` + -CertificateThumbprint A1B2C3D4E5F60718293A4B5C6D7E8F9012345678 #> [CmdletBinding()] param( @@ -42,12 +62,21 @@ param( [string] $Version = '1.0.0.0', [string] $IdentityName = 'CursorLang', - [string] $Publisher = 'CN=Aleksandr Neychev', - [string] $PublisherDisplayName = 'Aleksandr Neychev', + [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 @@ -64,7 +93,14 @@ $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' } +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' @@ -76,6 +112,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) { # 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 @@ -160,11 +216,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 @@ -189,6 +268,7 @@ foreach ($installed in @(Get-AppxPackage -Name $IdentityName)) { 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) { @@ -223,7 +303,7 @@ foreach ($architecture in $Architectures) { Set-Content -Path (Join-Path $layout 'AppxManifest.xml') -Value $manifest -Encoding UTF8 - $package = Join-Path $packagesPath "CursorLang-$Version-$architecture.msix" + $package = Join-Path $packagesPath "CursorLang-$Version-$architecture$suffix.msix" Invoke-Tool -Path $makeappx -Arguments @('pack', '/o', '/d', $layout, '/p', $package) $built += $package @@ -242,12 +322,23 @@ if ($built.Count -gt 1) { New-Item -ItemType Directory -Path $bundleInput -Force | Out-Null $built | ForEach-Object { Copy-Item $_ -Destination $bundleInput } - $result = Join-Path $packagesPath "CursorLang-$Version.msixbundle" + $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 @@ -261,7 +352,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 '' diff --git a/README.RU.md b/README.RU.md index c5bbf09..12fed51 100644 --- a/README.RU.md +++ b/README.RU.md @@ -186,8 +186,8 @@ MSIX всегда выполняются в контексте вошедшег Windows: тот показывает издателя, спрашивает подтверждение и заменяет установленную версию. Подпись проверяет Windows, поэтому приложенный к выпуску пакет должен быть подписан — неподписанный установится только на машине в режиме -разработчика. Работающее приложение до перезапуска продолжает жить на старых -файлах. +разработчика; пайплайн подписывает то, что прикладывает. Работающее приложение +до перезапуска продолжает жить на старых файлах. У приложения, установленного из Store, раздела обновлений нет вовсе: его обновляет Store, а пакет со стороны Windows поверх него всё равно не примет. @@ -263,8 +263,8 @@ dotnet test --collect:"XPlat Code Coverage" --settings coverage.runsettings Пайплайны лежат в `.gitea/workflows` и работают на Gitea Actions. Запрос на слияние в `master` собирается и проверяется тестами; по тегу вида `v1.2.3` проект собирается, проходит тесты, пакуется в MSIX и выкладывается релизом -с приложенными пакетами. Номер версии берётся только из тега — тег любого -другого вида останавливает прогон в самом начале. Версия пакета получается +с приложенными подписанными пакетами. Номер версии берётся только из тега — тег +любого другого вида останавливает прогон в самом начале. Версия пакета получается `1.2.3.0`: Store принимает четыре числа и последнее оставляет себе, так что тег на него не влияет. @@ -276,10 +276,29 @@ dotnet test --collect:"XPlat Code Coverage" --settings coverage.runsettings окно на переднем плане или каретку, — на runner’е, живущем службой в нулевом сеансе, пропускают себя: показать окно там негде. -Пакет, который несёт релиз, загружается в Partner Center как есть. Identity -берётся из переменных репозитория, а если те не заданы — из значений по -умолчанию в скрипте: `MSIX_IDENTITY_NAME`, `MSIX_PUBLISHER` -и `MSIX_PUBLISHER_DISPLAY_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 @@ -297,13 +316,21 @@ powershell -File Packaging\build-msix.ps1 -Architectures x64 # Для Partner Center — identity та, что зарезервирована там powershell -File Packaging\build-msix.ps1 -Version 1.0.1.0 ` - -IdentityName 12345AleksandrNeychev.CursorLang -Publisher "CN=ABCD1234-..." + -IdentityName 12345AleksandrNeichev.CursorLang -Publisher "CN=ABCD1234-..." ``` Результат — `artifacts\packages\CursorLang-<версия>.msixbundle`, покрывающий x64 и arm64; рядом лежат пакеты отдельных архитектур. Bundle загружается в Partner Center как есть. +`-CertificateThumbprint` подписывает всё, что собрано, сертификатом с этим +отпечатком из личного хранилища текущего пользователя; `-Publisher` тогда должен +называть его subject — скрипт говорит об этом до сборки, а не после. Обычно +подписывает пайплайн, но то же работает и руками, как только eSigner CKA — или +сертификат любого другого рода — положит сертификат в хранилище. +`-PackageSuffix` дописывается в конец имён файлов: сборки для Store и для релиза +различаются тем, что внутри, и больше ничем. + Приложение поставляется с собственной копией .NET: Windows не включает .NET 10, а MSIX не может установить среду выполнения как зависимость пакета. @@ -324,5 +351,5 @@ pwsh -File Packaging\build-msix.ps1 -Architectures x64 -Install Удалить вручную: ```powershell -Remove-AppxPackage (Get-AppxPackage -Name AleksandrNeychev.CursorLang).PackageFullName +Remove-AppxPackage (Get-AppxPackage -Name AleksandrNeichev.CursorLang).PackageFullName ``` diff --git a/README.md b/README.md index a1bff5b..2345f61 100644 --- a/README.md +++ b/README.md @@ -181,8 +181,8 @@ 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 running app keeps working off the old files until it is -restarted. +developer mode; the pipeline signs what it attaches. The running app keeps +working off the old files until it is restarted. An app installed from the Store has no updates section at all: the Store updates it, and a package from the side is something Windows would not accept @@ -257,7 +257,7 @@ 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, packed into an MSIX and published as a release with the packages +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 @@ -271,9 +271,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 the release carries goes to Partner Center as it is. 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 package is built twice over, because the Store and a release want different +things of it. + +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 @@ -291,13 +313,22 @@ powershell -File Packaging\build-msix.ps1 -Architectures x64 # For Partner Center — the identity is the one reserved there powershell -File Packaging\build-msix.ps1 -Version 1.0.1.0 ` - -IdentityName 12345AleksandrNeychev.CursorLang -Publisher "CN=ABCD1234-..." + -IdentityName 12345AleksandrNeichev.CursorLang -Publisher "CN=ABCD1234-..." ``` The result is `artifacts\packages\CursorLang-.msixbundle` covering x64 and arm64; next to it lie the packages of single architectures. Upload the bundle to Partner Center as it is. +`-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. @@ -318,5 +349,5 @@ the Store answers to the same name and is left alone. To remove it by hand: ```powershell -Remove-AppxPackage (Get-AppxPackage -Name AleksandrNeychev.CursorLang).PackageFullName +Remove-AppxPackage (Get-AppxPackage -Name AleksandrNeichev.CursorLang).PackageFullName ```