From 1aa9d9b44eb187a7acc4493a7d1257955d765caf Mon Sep 17 00:00:00 2001 From: Aleksandr Neychev Date: Thu, 13 Aug 2026 02:21:50 +0500 Subject: [PATCH 1/4] modified release pipeline --- .gitea/workflows/release.yml | 177 ++++++++++++++++++++++++++++++----- Packaging/build-msix.ps1 | 136 ++++++++++++++++++++++++--- README.RU.md | 74 +++++++++++---- README.md | 76 +++++++++++---- 4 files changed, 393 insertions(+), 70 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 9dce398..ccbc0cf 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -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 } diff --git a/Packaging/build-msix.ps1 b/Packaging/build-msix.ps1 index f8daf26..e297160 100644 --- a/Packaging/build-msix.ps1 +++ b/Packaging/build-msix.ps1 @@ -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 '' diff --git a/README.RU.md b/README.RU.md index ddf543c..6e6200e 100644 --- a/README.RU.md +++ b/README.RU.md @@ -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 не может установить среду выполнения как зависимость пакета. diff --git a/README.md b/README.md index 9a34915..998366d 100644 --- a/README.md +++ b/README.md @@ -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. -- 2.55.0 From f75ab0f077c4cfbc8df66154f243adb348f5c065 Mon Sep 17 00:00:00 2001 From: Aleksandr Neychev Date: Thu, 13 Aug 2026 14:58:26 +0500 Subject: [PATCH 2/4] removed update --- .gitea/workflows/release.yml | 218 +++-------------------------------- Packaging/build-msix.ps1 | 109 ++---------------- README.RU.md | 75 ++++-------- README.md | 77 +++---------- 4 files changed, 64 insertions(+), 415 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index ccbc0cf..23834ff 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -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 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 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 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 and -# signtool come 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 comes +# with a NuGet package (Packaging\Tools\SdkTools.csproj), so the Windows SDK does +# not have to be installed. name: Release on: @@ -84,25 +84,6 @@ 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 @@ -123,12 +104,7 @@ 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. - # - # 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 + - name: Pack the MSIX env: IDENTITY_NAME: ${{ vars.MSIX_IDENTITY_NAME }} PUBLISHER: ${{ vars.MSIX_PUBLISHER }} @@ -136,11 +112,7 @@ jobs: run: | $ErrorActionPreference = 'Stop' - $arguments = @{ - Version = '${{ steps.version.outputs.version }}' - PackageSuffix = 'store' - OutputPath = 'artifacts/store' - } + $arguments = @{ Version = '${{ steps.version.outputs.version }}' } # An empty variable is left out rather than passed on: the script has # defaults of its own, and an empty string would wipe them @@ -156,172 +128,10 @@ jobs: ./Packaging/build-msix.ps1 @arguments - - 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 + # The artifact is where the package waits to be uploaded to Partner Center + - name: Keep the package uses: actions/upload-artifact@v4 with: name: msix-${{ steps.version.outputs.version }} - path: artifacts/release/packages/ + path: artifacts/packages/ if-no-files-found: error - - # The .wixpdb next to each installer is left out on purpose: it is of use - # only when something has to be traced back to the WiX source - - name: Keep the installer - uses: actions/upload-artifact@v4 - with: - name: installer-${{ steps.version.outputs.plain }} - path: artifacts/installers/*.msi - if-no-files-found: error - - # Only the installers go into the release. The MSIX stays in the artifacts - # of the run: unsigned, it installs nowhere, and its one destination is - # Partner Center - - name: Publish the release - env: - GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} - TAG: ${{ github.ref_name }} - run: | - $ErrorActionPreference = 'Stop' - - # The GITHUB_ names are what Gitea itself hands to the workflow — its - # actions repeat those of GitHub, and the addresses in them point at - # this Gitea instance. GITHUB_API_URL used not to reach the steps at - # all, so the address is put together from the server one when empty - $root = if ($env:GITHUB_API_URL) { $env:GITHUB_API_URL } else { "$env:GITHUB_SERVER_URL/api/v1" } - $api = "$root/repos/$env:GITHUB_REPOSITORY/releases" - $headers = @{ Authorization = "token $env:GITEA_TOKEN" } - - # Gitea makes a release of its own for a pushed tag, so the release is - # looked up first and only made when it is not there - $release = $null - try { $release = Invoke-RestMethod "$api/tags/$env:TAG" -Headers $headers } catch { } - - if (-not $release) { - $body = @{ tag_name = $env:TAG; name = $env:TAG; draft = $false; prerelease = $false } | ConvertTo-Json - $release = Invoke-RestMethod $api -Method Post -Headers $headers -ContentType 'application/json' -Body $body - } - - # 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 } - foreach ($asset in $existing) { - Invoke-RestMethod "$api/$($release.id)/assets/$($asset.id)" -Method Delete -Headers $headers | Out-Null - } - - Write-Host "Uploading $($file.Name)" - Invoke-RestMethod "$api/$($release.id)/assets?name=$($file.Name)" -Method Post -Headers $headers -Form @{ attachment = $file } | Out-Null - } diff --git a/Packaging/build-msix.ps1 b/Packaging/build-msix.ps1 index e297160..b2144a3 100644 --- a/Packaging/build-msix.ps1 +++ b/Packaging/build-msix.ps1 @@ -3,15 +3,14 @@ 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. + 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. - 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 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 application is published with its own copy of .NET: Windows does not carry one, and MSIX cannot install the runtime as a package dependency. @@ -21,17 +20,6 @@ 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 @@ -43,14 +31,8 @@ .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 + -IdentityName 12345AleksandrNeichev.CursorLang -Publisher "CN=ABCD1234-..." #> [CmdletBinding()] param( @@ -64,15 +46,6 @@ param( [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 @@ -108,26 +81,6 @@ 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 @@ -205,34 +158,11 @@ 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 @@ -257,7 +187,6 @@ 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 = @() $layout = Join-Path $layoutRoot 'x64' @@ -289,7 +218,7 @@ $manifest = (Get-Content $manifestTemplate -Raw). Set-Content -Path (Join-Path $layout 'AppxManifest.xml') -Value $manifest -Encoding UTF8 - $package = Join-Path $packagesPath "CursorLang-$Version-$architecture$suffix.msix" + $package = Join-Path $packagesPath "CursorLang-$Version-$architecture.msix" Invoke-Tool -Path $makeappx -Arguments @('pack', '/o', '/d', $layout, '/p', $package) $built += $package @@ -308,23 +237,12 @@ 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$suffix.msixbundle" + $result = Join-Path $packagesPath "CursorLang-$Version.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 @@ -338,12 +256,7 @@ 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.' -} +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 6e6200e..7682f9e 100644 --- a/README.RU.md +++ b/README.RU.md @@ -167,27 +167,8 @@ MSIX всегда выполняются в контексте вошедшег ## Обновления -Проверка выполняется при открытии окна настроек, а не при включении машины: -фоновая половина в сеть больше не ходит вовсе, да и показать ответ ей нечем. -Настройка в окне так и написана. - -Приложение ищет новые версии среди выпусков собственного репозитория. Выпуск -годится, если его тег — это просто версия (`v1.2.3` или `1.2.3`) и к нему -приложен пакет MSIX. Тег, в котором есть что-то ещё, — в том числе `v1.2.3-beta` -— пропускается: предварительную версию берут намеренно, приложение её не -предлагает. - -Из приложенных файлов предпочитается `.msixbundle` — он несёт обе архитектуры. -Если его нет, берётся пакет, в имени которого стоит архитектура этой машины: -`CursorLang-1.2.3.0-x64.msix`. Такие имена даёт `build-msix.ps1`, так что выпуск -делается прикладыванием того, что он собрал. - -Пакет скачивается во временную папку и передаётся установщику приложений -Windows: тот показывает издателя, спрашивает подтверждение и заменяет -установленную версию. Подпись проверяет Windows, поэтому приложенный к выпуску -пакет должен быть подписан — неподписанный установится только на машине в режиме -разработчика; пайплайн подписывает то, что прикладывает. Работающее приложение -до перезапуска продолжает жить на старых файлах. +Приложение обновляет Store, а само приложение об этом не заботится: раздела +обновлений в окне нет, запросов в сеть нет и кода для них тоже нет. Дело не во вкусе, а в цене подписи. MSIX Windows установит только тогда, когда доверяет подписи на нём, а публично доверенный сертификат для подписи кода @@ -246,11 +227,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: без них иконка @@ -260,29 +241,18 @@ dotnet test --collect:"XPlat Code Coverage" --settings coverage.runsettings окно на переднем плане или каретку, — на runner’е, живущем службой в нулевом сеансе, пропускают себя: показать окно там негде. -Пакет собирается дважды: Store и релиз хотят от него разного. +Пакет идёт в Store и больше никуда: он не подписан, подпись на него ставит сам +Partner Center. Поэтому прогон оставляет его в артефактах под именем +`msix-1.2.3.0`, откуда его забирают и загружают руками; к релизу не прикладывается +ничего — релиз по тегу Gitea заводит сама, и в нём один только тег. Неподписанный +пакет, висящий в релизе, выглядел бы как то, что можно установить, и не +устанавливался бы нигде — см. раздел об обновлениях. -Тот, что для 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 пакета, поэтому от релиза к -релизу оба должны оставаться прежними — иначе обновление встанет рядом со старой -версией, а не заменит её. +Identity берётся из переменных репозитория, а если те не заданы — из значений по +умолчанию в скрипте: `MSIX_IDENTITY_NAME`, `MSIX_PUBLISHER` +и `MSIX_PUBLISHER_DISPLAY_NAME`. Вместе identity и publisher задают family name +пакета, поэтому от версии к версии оба должны оставаться прежними — иначе Store +примет следующую за другое приложение. ## Сборка пакета MSIX @@ -312,13 +282,8 @@ Partner Center как есть. Здесь ничего не подписывается: подпись на пакет ставит сам Store, а для установки на эту машину вместо пакета регистрируется layout — см. `-Install` ниже. -`-CertificateThumbprint` подписывает всё, что собрано, сертификатом с этим -отпечатком из личного хранилища текущего пользователя; `-Publisher` тогда должен -называть его subject — скрипт говорит об этом до сборки, а не после. Обычно -подписывает пайплайн, но то же работает и руками, как только eSigner CKA — или -сертификат любого другого рода — положит сертификат в хранилище. -`-PackageSuffix` дописывается в конец имён файлов: сборки для Store и для релиза -различаются тем, что внутри, и больше ничем. +Здесь ничего не подписывается: подпись на пакет ставит сам Store, а для установки +на эту машину вместо пакета регистрируется layout — см. `-Install` ниже. Приложение поставляется с собственной копией .NET: Windows не включает .NET 10, а MSIX не может установить среду выполнения как зависимость пакета. diff --git a/README.md b/README.md index 998366d..0a03655 100644 --- a/README.md +++ b/README.md @@ -162,27 +162,8 @@ after a switch of the mode, and that is a question asked once. ## Updates -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. +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. 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 @@ -241,11 +222,10 @@ 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 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. +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. 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 @@ -255,31 +235,18 @@ 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 is built twice over, because the Store and a release want different -things of it. +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 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. +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. ## Building the MSIX package @@ -309,14 +276,8 @@ 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. +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. 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. -- 2.55.0 From 2c46f41db8b505dae2f73dc0b28f1cf146b80ec6 Mon Sep 17 00:00:00 2001 From: Aleksandr Neychev Date: Thu, 13 Aug 2026 16:13:49 +0500 Subject: [PATCH 3/4] added msi to release pipeline --- .gitea/workflows/release.yml | 59 +++++++++ Packaging/Installer/CursorLang.wixproj | 48 ++++++++ Packaging/build-installer.ps1 | 163 +++++++++++++++++++++++++ Packaging/build-msix.ps1 | 31 +---- README.RU.md | 3 - README.md | 3 - 6 files changed, 273 insertions(+), 34 deletions(-) create mode 100644 Packaging/Installer/CursorLang.wixproj create mode 100644 Packaging/build-installer.ps1 diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 23834ff..9dce398 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -84,6 +84,10 @@ 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 + - name: Show the toolchain run: dotnet --info @@ -128,6 +132,12 @@ 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 }} + # The artifact is where the package waits to be uploaded to Partner Center - name: Keep the package uses: actions/upload-artifact@v4 @@ -135,3 +145,52 @@ jobs: name: msix-${{ steps.version.outputs.version }} path: artifacts/packages/ if-no-files-found: error + + # The .wixpdb next to each installer is left out on purpose: it is of use + # only when something has to be traced back to the WiX source + - name: Keep the installer + uses: actions/upload-artifact@v4 + with: + name: installer-${{ steps.version.outputs.plain }} + path: artifacts/installers/*.msi + if-no-files-found: error + + # Only the installers go into the release. The MSIX stays in the artifacts + # of the run: unsigned, it installs nowhere, and its one destination is + # Partner Center + - name: Publish the release + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + TAG: ${{ github.ref_name }} + run: | + $ErrorActionPreference = 'Stop' + + # The GITHUB_ names are what Gitea itself hands to the workflow — its + # actions repeat those of GitHub, and the addresses in them point at + # this Gitea instance. GITHUB_API_URL used not to reach the steps at + # all, so the address is put together from the server one when empty + $root = if ($env:GITHUB_API_URL) { $env:GITHUB_API_URL } else { "$env:GITHUB_SERVER_URL/api/v1" } + $api = "$root/repos/$env:GITHUB_REPOSITORY/releases" + $headers = @{ Authorization = "token $env:GITEA_TOKEN" } + + # Gitea makes a release of its own for a pushed tag, so the release is + # looked up first and only made when it is not there + $release = $null + try { $release = Invoke-RestMethod "$api/tags/$env:TAG" -Headers $headers } catch { } + + if (-not $release) { + $body = @{ tag_name = $env:TAG; name = $env:TAG; draft = $false; prerelease = $false } | ConvertTo-Json + $release = Invoke-RestMethod $api -Method Post -Headers $headers -ContentType 'application/json' -Body $body + } + + foreach ($file in Get-ChildItem artifacts/installers -File -Filter *.msi) { + # 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 } + foreach ($asset in $existing) { + Invoke-RestMethod "$api/$($release.id)/assets/$($asset.id)" -Method Delete -Headers $headers | Out-Null + } + + Write-Host "Uploading $($file.Name)" + Invoke-RestMethod "$api/$($release.id)/assets?name=$($file.Name)" -Method Post -Headers $headers -Form @{ attachment = $file } | Out-Null + } diff --git a/Packaging/Installer/CursorLang.wixproj b/Packaging/Installer/CursorLang.wixproj new file mode 100644 index 0000000..3877049 --- /dev/null +++ b/Packaging/Installer/CursorLang.wixproj @@ -0,0 +1,48 @@ + + + + + CursorLang + Package + + + ICE38;ICE64;ICE91 + + + + Version=$(CursorLangVersion); + PayloadDir=$(PayloadDir); + IconFile=$(IconFile); + LicenseFile=$(LicenseFile) + + + + + + + + + + diff --git a/Packaging/build-installer.ps1 b/Packaging/build-installer.ps1 new file mode 100644 index 0000000..61445a2 --- /dev/null +++ b/Packaging/build-installer.ps1 @@ -0,0 +1,163 @@ +<# +.SYNOPSIS + Builds the CursorLang installer for handing the application round outside + the Store. + +.DESCRIPTION + Nothing has to be installed beyond the .NET SDK: WiX arrives as a NuGet + package, the same way makeappx does for the MSIX build. + + Nobody signs the result, so Windows warns about an unknown publisher and the + user has to insist. Short of a certificate from a trusted authority there is + no way round that, and it is the reason this installer is meant for people who + already know where the file came from. + + The application is published with its own copy of .NET: Windows carries no + runtime of its own, and a self-contained build is tied to x64. + +.PARAMETER Install + Runs the installer once it is built, to see the thing through the way a user + would. + +.EXAMPLE + # Build and run it, to see what a user sees + pwsh -File Packaging\build-installer.ps1 -Install + +.EXAMPLE + # What a release needs + pwsh -File Packaging\build-installer.ps1 -Version 1.0.1 +#> +[CmdletBinding()] +param( + # Three numbers: unlike the Store, an installer keeps no place for a fourth + [string] $Version = '1.0.0', + + [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' +$installerProject = Join-Path $root 'Installer\CursorLang.wixproj' +$icon = Join-Path $repository 'CursorLang.Core\Resources\CursorLang.ico' +$license = Join-Path $repository 'LICENSE' + +if (-not $OutputPath) { $OutputPath = Join-Path $repository 'artifacts' } +$stagingRoot = Join-Path $OutputPath 'installer-staging' +$installersPath = Join-Path $OutputPath 'installers' + +if ($Version -notmatch '^\d+\.\d+\.\d+$') { + throw "The version '$Version' does not fit: an installer is versioned by three numbers, for example 1.0.0." +} + +if (-not (Test-Path $icon)) { + throw "The icon is missing from '$icon'. Git LFS may have left a pointer in its place: run git lfs pull." +} + +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 Write-LicenseRtf { + <# + .SYNOPSIS + Turns the plain-text LICENSE into the RTF the wizard needs. + .DESCRIPTION + The licence page of a Windows Installer wizard reads RTF and nothing + else, and keeping a second copy of the licence in the repository would + mean keeping the two in step by hand. The conversion is as plain as it + looks: the text carries no formatting to preserve. + #> + param([string] $Path) + + $text = (Get-Content $license -Raw) -replace '\\', '\\\\' -replace '{', '\{' -replace '}', '\}' + $body = ($text -split "`r?`n") -join '\par' + "`r`n" + + Set-Content -Path $Path -Value "{\rtf1\ansi\deff0{\fonttbl{\f0 Segoe UI;}}\fs18 $body}" -Encoding ASCII +} + +Remove-Item $stagingRoot -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Path $installersPath -Force | Out-Null + +$licenseRtf = Join-Path $stagingRoot 'License.rtf' +New-Item -ItemType Directory -Path $stagingRoot -Force | Out-Null +Write-LicenseRtf -Path $licenseRtf + +Write-Host 'Building...' -ForegroundColor Cyan + +$staging = Join-Path $stagingRoot 'x64' + +# Both halves go into one folder: each of them looks for the other beside +# itself — the tray menu opens the settings window, and the settings window +# registers the agent for startup +foreach ($half in @($agentProject, $settingsProject)) { + Invoke-Tool -Path 'dotnet' -Arguments @( + 'publish', $half, + '--configuration', 'Release', + '--runtime', 'win-x64', + '--self-contained', 'true', + "-p:Version=$Version", + '--output', $staging, + '--nologo' + ) +} + +# Debug symbols only make the download bigger; they are of no use to anyone +# who installs the application +Get-ChildItem $staging -Recurse -Filter '*.pdb' | Remove-Item -Force + +$installer = Join-Path $installersPath "CursorLang-$Version-x64.msi" + +Invoke-Tool -Path 'dotnet' -Arguments @( + 'build', $installerProject, + '--configuration', 'Release', + # The architecture of the package is the architecture of what goes in it: + # a self-contained build fits nothing else + '-p:InstallerPlatform=x64', + # Handed over one by one rather than as a ready list of constants: a + # semicolon in a property value is where MSBuild stops reading it. The + # project gathers them into DefineConstants itself + "-p:CursorLangVersion=$Version", + "-p:PayloadDir=$staging", + "-p:IconFile=$icon", + "-p:LicenseFile=$licenseRtf", + "-p:OutputPath=$installersPath\", + "-p:OutputName=CursorLang-$Version-x64", + '--nologo' +) + +if ($Install) { + Write-Host "Running $([System.IO.Path]::GetFileName($installer))..." -ForegroundColor Cyan + + # Started the way a user would, with the wizard showing: the point of -Install + # is to see what the person on the other end sees + Start-Process 'msiexec.exe' -ArgumentList '/i', "`"$installer`"" -Wait +} + +Write-Host '' +Write-Host 'Done.' -ForegroundColor Green +Write-Host " $installer" + +Write-Host '' +Write-Host ' Nobody signed it, so Windows warns about an unknown publisher.' diff --git a/Packaging/build-msix.ps1 b/Packaging/build-msix.ps1 index b2144a3..f8daf26 100644 --- a/Packaging/build-msix.ps1 +++ b/Packaging/build-msix.ps1 @@ -43,9 +43,6 @@ param( [string] $Publisher = 'CN=Aleksandr Neichev', [string] $PublisherDisplayName = 'Aleksandr Neichev', - [ValidateSet('x64', 'arm64')] - [string[]] $Architectures = @('x64', 'arm64'), - [switch] $Install, [string] $OutputPath @@ -187,7 +184,7 @@ foreach ($installed in @(Get-AppxPackage -Name $IdentityName)) { Remove-Item $layoutRoot -Recurse -Force -ErrorAction SilentlyContinue New-Item -ItemType Directory -Path $packagesPath -Force | Out-Null -$built = @() +Write-Host 'Building...' -ForegroundColor Cyan $layout = Join-Path $layoutRoot 'x64' @@ -218,30 +215,8 @@ $manifest = (Get-Content $manifestTemplate -Raw). Set-Content -Path (Join-Path $layout 'AppxManifest.xml') -Value $manifest -Encoding UTF8 - $package = Join-Path $packagesPath "CursorLang-$Version-$architecture.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.msixbundle" - Invoke-Tool -Path $makeappx -Arguments @('bundle', '/o', '/d', $bundleInput, '/p', $result, '/bv', $Version) - - Remove-Item $bundleInput -Recurse -Force -} +$result = Join-Path $packagesPath "CursorLang-$Version-x64.msix" +Invoke-Tool -Path $makeappx -Arguments @('pack', '/o', '/d', $layout, '/p', $result) if ($Install) { Write-Host 'Installing...' -ForegroundColor Cyan diff --git a/README.RU.md b/README.RU.md index 7682f9e..ddf543c 100644 --- a/README.RU.md +++ b/README.RU.md @@ -282,9 +282,6 @@ Partner Center как есть. Здесь ничего не подписывается: подпись на пакет ставит сам Store, а для установки на эту машину вместо пакета регистрируется layout — см. `-Install` ниже. -Здесь ничего не подписывается: подпись на пакет ставит сам Store, а для установки -на эту машину вместо пакета регистрируется layout — см. `-Install` ниже. - Приложение поставляется с собственной копией .NET: Windows не включает .NET 10, а MSIX не может установить среду выполнения как зависимость пакета. diff --git a/README.md b/README.md index 0a03655..9a34915 100644 --- a/README.md +++ b/README.md @@ -276,9 +276,6 @@ 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. -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. - 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. -- 2.55.0 From de4352764223e2d5e8c1f7efd6e4b9535f457fd9 Mon Sep 17 00:00:00 2001 From: Aleksandr Neychev Date: Thu, 13 Aug 2026 16:50:20 +0500 Subject: [PATCH 4/4] added documentaion for publishing --- Packaging/PUBLISHING.RU.md | 383 +++++++++++++++++++++++++++++++++++++ 1 file changed, 383 insertions(+) create mode 100644 Packaging/PUBLISHING.RU.md diff --git a/Packaging/PUBLISHING.RU.md b/Packaging/PUBLISHING.RU.md new file mode 100644 index 0000000..bbe08b3 --- /dev/null +++ b/Packaging/PUBLISHING.RU.md @@ -0,0 +1,383 @@ +# Публикация в Microsoft Store + +Инструкция с нуля: от регистрации разработчика до отправки пакета на проверку. +Документ существует только на русском — переводить его не нужно. + +Ссылки на первоисточники стоят по ходу текста, сводный список — в конце. +Правила Store меняются, поэтому перед подачей стоит сверяться с ними, а не с +пересказом здесь: этот документ отражает положение дел на август 2026 года. + +## 1. Аккаунт разработчика + +С 2026 года регистрация бесплатна для обоих типов аккаунта. Прежние взносы +($19 и $99) отменены — [объявление для компаний][free-company], +[объявление для частных лиц][free-individual]. Новостные заметки за 2025 год, +где у Company ещё указан взнос $99, устарели. + +**Начинать нужно с https://storedeveloper.microsoft.com** — это единственный +вход в бесплатный процесс регистрации. Если зайти через Partner Center, Visual +Studio или Xbox, откроется старый порядок со взносом. + +### Какой тип выбрать + +Критерии обоих типов и порядок регистрации — [«Открытие аккаунта +разработчика»][open-account]. + +| | Individual | Company | +|---|---|---| +| Кому | частное лицо, хобби, некоммерческие проекты | ИП, ТОО, организация — всё, что связано с предпринимательской деятельностью | +| Имя издателя в Store | ваше имя | название организации | +| Вход | только личный аккаунт Microsoft | личный аккаунт или рабочий Microsoft Entra ID | +| Проверка | документ с фотографией и селфи | D-U-N-S или уставные документы плюс рабочая почта на домене организации | +| Срок | минуты, если снимки читаемые | от минут до 2–5 рабочих дней при ручной проверке | + +Company доступен только тем, у кого уже есть зарегистрированное юридическое лицо +или ИП: проверка требует либо номера D-U-N-S, либо регистрационных документов. +Без них остаётся Individual, и для личного бесплатного приложения этого +достаточно. + +Три вещи после регистрации **не меняются** — тип аккаунта, страна и имя издателя +([FAQ по управлению аккаунтом][account-faq]). Что с этим делать при отсутствии +компании и планах на платный продукт, разобрано в +[разделе 9](#9-регистрация-из-казахстана). + +### Что подготовить + +**Для Individual:** удостоверение личности или паспорт (оригинал, не копия) и +телефон с камерой — снимок документа и селфи делаются на мобильном. + +**Для Company:** +- номер D-U-N-S, если он есть — тогда данные подтянутся автоматически и проверка + пройдёт быстрее. Иначе понадобится справка о регистрации, устав или выписка из + государственного реестра; +- почта на домене организации. Gmail и подобные не принимаются; если домен почты + не совпадает с доменом организации, попросят подтвердить владение доменом — + счётом от регистратора или записью из реестра доменов. + +На каждый вид проверки даётся не более трёх попыток, поэтому данные лучше +перепроверить до отправки. Ответить на запрос документов нужно в течение 30 дней. + +### Налоги и выплаты + +Приложение бесплатное, встроенных покупок и рекламы нет — значит платёжный +профиль и налоговые формы не нужны ([FAQ по управлению аккаунтом][account-faq]). +Они потребуются, только если появятся платные функции, — тогда см. +[раздел 8](#8-если-приложение-будет-платным). + +После создания аккаунта данные расходятся по Partner Center до получаса. Если +раздел «Apps & Games» не появился сразу — подождать и обновить страницу. + +## 2. Резервирование имени + +Partner Center → **Apps and games** → **New product** → **MSIX or PWA app**. +Ввести имя, нажать **Check availability**, затем **Reserve product name**. + +Подробности — [«Резервирование имени приложения MSIX»][reserve-name] и +[«Управление резервированием имён»][manage-names]. + +Имя можно занять за три месяца до публикации, даже если приложение ещё не +готово. Стоит сразу занять запасные написания — например `CursorLang` и +`Cursor Lang`. + +## 3. Данные пакета + +После резервирования: **Product management** → **Product identity**. На странице +будут три значения: + +| Значение в Partner Center | Куда подставить | +|---|---| +| Package/Identity/Name | параметр `-IdentityName` | +| Package/Identity/Publisher | параметр `-Publisher`, вида `CN=` и длинный идентификатор | +| Package/Properties/PublisherDisplayName | параметр `-PublisherDisplayName` | + +Эти значения должны совпадать с манифестом пакета до символа. Расхождение даёт +при загрузке невнятную ошибку, которая не называет поле, — поэтому копировать +их нужно буквально, а не набирать руками. Разбор таких ошибок — +[«Устранение ошибок отправки MSIX»][submission-errors]. + +В сборочном конвейере эти значения лежат в переменных репозитория +`MSIX_IDENTITY_NAME`, `MSIX_PUBLISHER` и `MSIX_PUBLISHER_DISPLAY_NAME`. Это +переменные, а не секреты: identity виден в любом опубликованном пакете, а +маскирование в логах только мешало бы разбирать ошибки сборки. + +## 4. Сборка пакета + +```powershell +pwsh -File Packaging\build-msix.ps1 ` + -Version 1.0.0.0 ` + -IdentityName "" ` + -Publisher "" ` + -PublisherDisplayName "" +``` + +Получится `artifacts\packages\CursorLang-1.0.0.0-x64.msix`. Загружать его нужно +**без подписи**: Store подписывает пакет своим сертификатом. + +Версия при каждой следующей отправке должна расти, а последнее число всегда +остаётся нулём. + +## 5. Заявка + +**Product release** → **Start submission**. Разделы можно заполнять в любом +порядке. Полный перечень полей с пометками обязательности — +[«Создание заявки для приложения MSIX»][create-submission]. + +### Pricing and availability + +Обязательны рынки, аудитория, обнаружимость, расписание и цена. Всё, кроме цены, +имеет разумные значения по умолчанию — цену выставить **Free** +([подробности о ценах и доступности][price-availability]). + +### Properties + +- **Category** — обязательна. Подходит *Productivity* либо *Utilities & tools*. +- **Privacy policy URL** — обязателен, только если приложение собирает или + передаёт персональные данные. CursorLang не собирает ничего: настройки лежат + в папке приложения, наружу ничего не уходит. Поле можно оставить пустым, но + безопаснее выложить короткую страницу с фразой о том, что данные не + собираются, — это снимает вопросы у проверяющих. +- **Contact details** — обязательны для аккаунта Company. + +### Age ratings + +Обязательна вся анкета. Приложение не игровое, ничего не собирает — ответы +однозначные, рейтинг присвоится автоматически. + +### Packages + +Загрузить `.msix`. Раздел остаётся «Incomplete», пока не заполнены все +обязательные поля, даже если сам пакет уже помечен как «Validated». + +### Store listings + +Хотя бы для одного языка. Стоит заполнить и английский, и русский — приложение +переведено на оба. Основным лучше сделать английский: он работает как запасной +вариант для рынков, где отдельного перевода нет. + +Обязательны описание и **минимум один снимок экрана** (рекомендуется четыре и +больше). Снимки: PNG, от 1366×768. Показать стоит окно настроек и саму подсказку +у курсора — вторую снять сложнее, поможет отложенный снимок экрана. + +### Submission options + +- **Restricted capabilities** — заполнить обязательно, потому что пакет объявляет + `runFullTrust`. Для обычной программы рабочего стола это подтверждается без + разбирательств; в обосновании достаточно написать, что приложение — обычная + программа Windows, а не UWP. +- **Notes for certification** — не обязательно, но здесь лучше не молчать. + Приложение ставит низкоуровневый перехватчик клавиатуры, и без объяснений это + выглядит подозрительно. Стоит написать примерно так: + + > The app installs a WH_KEYBOARD_LL hook to offer an optional Caps Lock + > shortcut that switches the keyboard layout instead of toggling case. The + > hook only inspects the Caps Lock key, is off by default, and is enabled by + > the user in the app's settings. No keystrokes are recorded, stored or + > transmitted anywhere. + +Затем **Submit for certification**. + +## 6. Чего ждать от проверки + +Проверка занимает обычно от нескольких часов до трёх дней. Проверяющие смотрят +сам пакет, а не исходники: ссылка на репозиторий им не нужна и, скорее всего, +останется без внимания. Всё, что важно донести, идёт в Notes for certification. + +Слабые места именно этого приложения: + +- **Перехватчик клавиатуры.** Главный повод для вопросов. Снимается пояснением в + Notes for certification и тем, что настройка выключена по умолчанию. +- **Права администратора.** Их больше нет — ни в манифесте, ни в поведении. + Возможности `allowElevation` в пакете тоже нет. Возвращать их нельзя: + «приложения, которым права администратора нужны хоть для какой-то части + работы, в Store не принимаются» — [«Подготовка к упаковке»][prepare-package]. +- **Чистое удаление.** Настройки упакованной версии лежат в папке данных пакета + и уходят вместе с ним — это проверено. + +Общие требования, по которым идёт проверка, — [правила Microsoft Store][policies]. + +## 7. Обновления + +Порядок тот же, короче: поднять версию, собрать пакет теми же значениями +identity, создать новую заявку, загрузить пакет. Имя резервировать заново не +нужно. + +## 8. Если приложение будет платным + +Раздел на будущее: для CursorLang ничего из этого не нужно. + +### Нужен ли для этого Company + +Не обязательно. Платёжный профиль доступен обоим типам аккаунта, и запрета +продавать с Individual в документации нет ([FAQ по управлению +аккаунтом][account-faq]). Критерий Individual против Company описывает характер +деятельности ([типы аккаунтов][account-types]), а не техническую возможность +брать деньги. + +Практический вывод: заводить ИП **ради требований Microsoft** не нужно. Оно +скорее понадобится по местному праву — чтобы легально оформлять регулярный доход +из-за рубежа. Это вопрос к бухгалтеру, а не к Partner Center. + +Если всё же понадобится именно Company, помните: сменить тип у существующего +аккаунта нельзя, придётся заводить второй, а уже опубликованные приложения в +него штатно не переедут — см. [раздел 9](#9-регистрация-из-казахстана). + +### Платёжный и налоговый профиль + +Account settings → **Payout and tax profile** +([порядок настройки][payout-setup]). Без них платное приложение не отправить на +проверку. Понадобятся банковский счёт и налоговая форма: для тех, кто не платит +налоги в США, это W-8BEN (частное лицо) или W-8BEN-E (организация) +([налоговые сведения][tax-info], [налоговые обязанности][tax-details]). + +Между Казахстаном и США действует соглашение об избежании двойного +налогообложения, поэтому удержание с продаж в США ниже базовых 30%. Точную +ставку показывают при заполнении формы — гадать заранее не нужно. + +### Выплаты в Казахстан + +Поддерживаются. По [таблице выплат по регионам][payout-regions] для Казахстана +доступны и Microsoft Store, и PayPal. + +| | | +|---|---| +| Порог выплаты | 50 USD — ниже суммы накапливаются | +| PayPal | около одного рабочего дня | +| ACH/SEPA | два–три рабочих дня | +| Банковский перевод | семь–десять рабочих дней | + +### Доля Microsoft + +По действующим условиям — 15% с продаж приложений и 12% с игр; для неигровых +приложений можно подключить собственную платёжную систему и оставлять себе всю +выручку. Эти цифры широко приводятся в отраслевых публикациях, но отдельной +страницы с ними в документации нет: обязывающий документ — +[соглашение разработчика приложений][developer-agreement], и сверяться нужно с +его действующей редакцией. + +### Что меняется в самом приложении + +Почти ничего. Store выдаёт лицензию при покупке и без неё приложение не +устанавливает, так что проверка в коде не обязательна. При желании лицензию +читают через [`Windows.Services.Store`][store-api]. Пробный период включается в +Partner Center и правок в коде не требует. + +### Рынки + +Для платного приложения список стран стоит выбрать осознанно, а не оставлять +«все»: где-то автоматически пересчитанная цена окажется неуместной, где-то +появятся местные налоговые обязанности. + +## 9. Регистрация из Казахстана + +Раздел для случая, когда разработчик живёт и платит налоги в Казахстане, а +гражданство у него другое. Юридических советов здесь нет — только то, что +касается устройства Partner Center. + +### Страна аккаунта важнее гражданства + +Microsoft смотрит на страну проживания и налогового резидентства, а не на +паспорт. Страна указывается при регистрации и **после неё не меняется** +([FAQ по управлению аккаунтом][account-faq]); штатного способа исправить её нет, +остаётся обращение в поддержку без гарантии результата. + +Разница между Казахстаном и Россией существенная и необратимая: + +| | Казахстан | Россия | +|---|---|---| +| Выплаты Microsoft Store | да | да, но PayPal помечен как приостановленный | +| Налоговое соглашение с США | [действует][irs-kazakhstan], предел по роялти 10% | [приостановлено с 16 августа 2024][irs-russia] | +| Удержание в США | 10% при поданной форме W-8BEN | 30%, льготные ставки не применяются | + +Данные о выплатах — из [таблицы выплат по регионам][payout-regions], налоговые — +из перечня [действующих соглашений США][irs-treaties]. + +### Документ и автозаполнение профиля + +Регистрация Individual идёт через снимок документа и селфи, после чего профиль +**заполняется данными из документа**. Отсюда практическое правило: если есть +казахстанский документ — вид на жительство или удостоверение личности, — +верифицироваться лучше по нему. Если под рукой только иностранный паспорт, +страну и адрес нужно проверить и при необходимости исправить на казахстанские +**до** подтверждения профиля. + +### Имя издателя задаётся один раз + +Publisher display name — то, что видят пользователи в карточке приложения, — тоже +не меняется после регистрации. Если в Store должно значиться название бренда, а +не ФИО, это задаётся на шаге заполнения профиля. Переименование потом — только +через поддержку, с вероятным ответом «заводите новый аккаунт». + +### Приложения между аккаунтами не переносятся + +Штатной процедуры передачи приложения из одного аккаунта в другой в Partner +Center нет. Значит, если позже завести отдельный Company-аккаунт под платный +продукт, ранее опубликованное бесплатное приложение останется на первом. + +Для бесплатной утилиты это не проблема: она продолжит работать и обновляться там, +где опубликована. Но переиграть решение задним числом не выйдет, поэтому выбор +имени издателя и страны стоит сделать вдумчиво с первого раза. + +### Порядок действий + +1. Начать с https://storedeveloper.microsoft.com — иначе откроется старый платный + процесс. +2. Выбрать **Individual**, если зарегистрированного юридического лица или ИП нет. +3. Верифицироваться по казахстанскому документу, если он есть. +4. Проверить, что страна в профиле — Казахстан, а адрес казахстанский. +5. Проверить имя издателя: оно останется навсегда. +6. Платёжный и налоговый профиль пропустить — для бесплатного приложения он не + нужен. + +Когда дойдёт до платного приложения, добавится форма +[W-8BEN][w8ben] с указанием Казахстана как страны налогового резидентства и ИИН в +поле иностранного налогового номера. Сертификат налогового резидентства РК +Microsoft не запрашивает, но он подтверждает право на ставку 10%, если вопрос +возникнет. + +## Источники + +- [Открытие аккаунта разработчика][open-account] +- [Типы аккаунтов разработчика][account-types] +- [Бесплатная регистрация для компаний][free-company] +- [Бесплатная регистрация для частных лиц][free-individual] +- [FAQ по управлению аккаунтом][account-faq] +- [Резервирование имени приложения MSIX][reserve-name] +- [Управление резервированием имён][manage-names] +- [Создание заявки для приложения MSIX][create-submission] +- [Цены и доступность][price-availability] +- [Устранение ошибок отправки MSIX][submission-errors] +- [Подготовка к упаковке программы рабочего стола][prepare-package] +- [Правила Microsoft Store][policies] +- [Настройка платёжного и налогового профиля][payout-setup] +- [Выплаты по регионам][payout-regions] +- [Налоговые сведения][tax-info] +- [Налоговые обязанности][tax-details] +- [Соглашение разработчика приложений][developer-agreement] +- [API лицензий и покупок Windows.Services.Store][store-api] +- [Форма W-8BEN, IRS][w8ben] +- [Действующие налоговые соглашения США, IRS][irs-treaties] +- [Соглашение США — Казахстан, IRS][irs-kazakhstan] +- [Соглашение США — Россия и его приостановка, IRS][irs-russia] + +[open-account]: https://learn.microsoft.com/en-us/windows/apps/publish/partner-center/open-a-developer-account +[account-types]: https://learn.microsoft.com/en-us/windows/apps/publish/partner-center/partner-center-developer-account +[free-company]: https://learn.microsoft.com/en-us/windows/apps/publish/whats-new-company-developer +[free-individual]: https://learn.microsoft.com/en-us/windows/apps/publish/whats-new-individual-developer +[account-faq]: https://learn.microsoft.com/en-us/windows/apps/publish/faq/manage-your-account +[reserve-name]: https://learn.microsoft.com/en-us/windows/apps/publish/publish-your-app/msix/reserve-your-apps-name +[manage-names]: https://learn.microsoft.com/en-us/windows/apps/publish/partner-center/msix/manage-app-name-reservations +[create-submission]: https://learn.microsoft.com/en-us/windows/apps/publish/publish-your-app/msix/create-app-submission +[price-availability]: https://learn.microsoft.com/en-us/windows/apps/publish/publish-your-app/msix/price-and-availability +[submission-errors]: https://learn.microsoft.com/en-us/windows/apps/publish/publish-your-app/msix/resolve-submission-errors +[prepare-package]: https://learn.microsoft.com/en-us/windows/msix/desktop/desktop-to-uwp-prepare +[policies]: https://learn.microsoft.com/en-us/windows/apps/publish/store-policies +[payout-setup]: https://learn.microsoft.com/en-us/partner-center/account-settings/set-up-your-payout-account +[payout-regions]: https://learn.microsoft.com/en-us/partner-center/marketplace-offers/payment-thresholds-methods-timeframes +[tax-info]: https://learn.microsoft.com/en-us/partner-center/marketplace-offers/tax-information-for-commercial-marketplace +[tax-details]: https://learn.microsoft.com/en-us/partner-center/marketplace-offers/tax-details-marketplace +[developer-agreement]: https://learn.microsoft.com/en-us/legal/windows/agreements/app-developer-agreement +[store-api]: https://learn.microsoft.com/en-us/windows/uwp/monetize/in-app-purchases-and-trials +[w8ben]: https://www.irs.gov/forms-pubs/about-form-w-8-ben +[irs-treaties]: https://www.irs.gov/businesses/international-businesses/united-states-income-tax-treaties-a-to-z +[irs-kazakhstan]: https://www.irs.gov/businesses/international-businesses/kazakhstan-tax-treaty-documents +[irs-russia]: https://www.irs.gov/businesses/international-businesses/russia-tax-treaty-documents -- 2.55.0