# The release: a tag of the form v1.2.3 builds the solution, runs the tests and # packs the MSIX with the version taken from the tag — three numbers of the tag # and a zero the Store keeps for itself. # # The package is built twice, because the two places it goes to want different # things of it. The Store gets a package with the identity reserved in Partner # Center and no signature — Partner Center signs it there. The release gets a # package signed here, with SSL.com's certificate the private key of which never # leaves their HSM; Windows installs nothing else. The two only differ inside, # so the Store one carries a suffix in its name and never leaves the artifacts. # # The same requirements to the runner as in pull-request.yml apply: Windows, the # .NET 10 SDK and an interactive desktop session for the tests. makeappx and # signtool come with a NuGet package (Packaging\Tools\SdkTools.csproj), so the # Windows SDK does not have to be installed. name: Release on: push: tags: - 'v*' defaults: run: shell: pwsh jobs: release: runs-on: windows-x64 steps: - name: Check out the sources uses: actions/checkout@v4 # The exe icon and the MSIX logos live in Git LFS, and without them the # checkout leaves text pointers in their place — the build fails on the # icon and the package would carry broken logos. # # They are fetched here rather than by `lfs: true` on the checkout: that # way the objects arrive over a request the LFS endpoint accepts. See the # comment on the header below - name: Fetch the LFS objects run: | $ErrorActionPreference = 'Stop' # actions/checkout leaves its own token in the config as an # http./.extraheader, and git-lfs sends that header on to the # LFS endpoint, which turns down the token of a workflow: every object # comes back 401 and the fetch gives up. The repository is public and # its LFS objects are readable without a token at all, so the header # simply goes. A private repository would need credentials of its own # in lfs.url instead $keys = git config --local --list --name-only | Where-Object { $_ -like '*.extraheader' } foreach ($key in $keys) { git config --local --unset-all $key } git lfs pull if ($LASTEXITCODE -ne 0) { throw "git lfs pull ended with exit code $LASTEXITCODE." } # A pointer left in place of a file shows itself much later and in a # way that is hard to read back: the icon breaks the build, and a logo # quietly ends up broken inside the package $pointers = git lfs ls-files --name-only | Where-Object { (Get-Content $_ -TotalCount 1) -like 'version https://git-lfs*' } if ($pointers) { throw "Git LFS left pointers instead of files: $($pointers -join ', ')." } # The tag is the only place the version comes from, and it is a plain # version of three numbers — the same shape the application itself looks # for in the releases when it checks for an update. A tag of any other # shape is stopped here rather than halfway through the packaging - name: Read the version from the tag id: version run: | $ErrorActionPreference = 'Stop' $tag = '${{ github.ref_name }}' if ($tag -notmatch '^v\d+\.\d+\.\d+$') { throw "The tag '$tag' does not fit: a release is tagged as v1.2.3 — three numbers. A fourth one does not belong in the tag: the Store keeps the revision for itself, and the package always gets a zero there." } # The package takes four numbers with a zero at the end: the Store # 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 - name: Restore run: dotnet restore CursorLang.sln --nologo - name: Build run: dotnet build CursorLang.sln --configuration Release --no-restore --nologo - name: Test run: > dotnet test CursorLang.sln --configuration Release --no-build --nologo --settings coverage.runsettings # 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 env: IDENTITY_NAME: ${{ vars.MSIX_IDENTITY_NAME }} PUBLISHER: ${{ vars.MSIX_PUBLISHER }} PUBLISHER_DISPLAY_NAME: ${{ vars.MSIX_PUBLISHER_DISPLAY_NAME }} run: | $ErrorActionPreference = 'Stop' $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 $variables = @{ IdentityName = $env:IDENTITY_NAME Publisher = $env:PUBLISHER 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 Store packages uses: actions/upload-artifact@v4 with: name: msix-store-${{ steps.version.outputs.version }} path: artifacts/store/packages/ if-no-files-found: error # eSigner CKA is a key storage provider: it puts the certificate into the # store of this user and answers signtool's requests for the private key # over SSL.com's API, so the key itself never comes down to the runner. # From signtool's side it looks like a certificate on a token, minus the # token and minus the person who would plug it in. # # The TOTP secret is what stands in for that person: eSigner hands it out # once, for automated signing, and the tool makes the codes out of it # itself - name: Load the signing certificate id: certificate env: CKA_URL: https://github.com/SSLcom/eSignerCKA/releases/download/v1.0.6/SSL.COM-eSigner-CKA_1.0.6.zip ESIGNER_USERNAME: ${{ secrets.ESIGNER_USERNAME }} ESIGNER_PASSWORD: ${{ secrets.ESIGNER_PASSWORD }} ESIGNER_TOTP_SECRET: ${{ secrets.ESIGNER_TOTP_SECRET }} run: | $ErrorActionPreference = 'Stop' $temporary = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { $env:TEMP } $archive = Join-Path $temporary 'eSignerCKA.zip' $unpacked = Join-Path $temporary 'eSignerCKA' # Everything of the adapter's own — the installation and the master # key it keeps the account in — lives outside the workspace: the # workspace is what gets packed and uploaded $suite = Join-Path $env:USERPROFILE '.signingsuite' $installation = Join-Path $suite 'eSignerCKA' Remove-Item $unpacked -Recurse -Force -ErrorAction SilentlyContinue New-Item -ItemType Directory -Path $suite -Force | Out-Null Invoke-WebRequest -Uri $env:CKA_URL -OutFile $archive Expand-Archive -Path $archive -DestinationPath $unpacked -Force $installer = Get-ChildItem $unpacked -Recurse -Filter '*.exe' | Select-Object -First 1 if (-not $installer) { throw "No installer inside the eSigner CKA archive at '$env:CKA_URL'." } # /CURRENTUSER, so that the certificate lands in the store of the user # the build runs as — the same one signtool then looks in & $installer.FullName /CURRENTUSER /VERYSILENT /SUPPRESSMSGBOXES "/DIR=$installation" | Out-Null $tool = Join-Path $installation 'eSignerCKATool.exe' if (-not (Test-Path $tool)) { throw "eSigner CKA did not install: '$tool' is not there." } & $tool config -mode product -user $env:ESIGNER_USERNAME -pass $env:ESIGNER_PASSWORD -totp $env:ESIGNER_TOTP_SECRET -key (Join-Path $suite 'master.key') -r if ($LASTEXITCODE -ne 0) { throw "eSigner CKA turned down the account: the tool exited with code $LASTEXITCODE." } # A run of the same runner could have left a certificate loaded from # another account; unload says nothing when there is nothing to # unload, and its exit code is of no interest for that reason & $tool unload | Out-Null & $tool load if ($LASTEXITCODE -ne 0) { throw "eSigner CKA could not load the certificate: the tool exited with code $LASTEXITCODE." } # An account may hold more than one certificate — a renewal leaves the # old one behind — and the one that lives longest is the one to sign # with: a signature made by a certificate about to expire is timestamped # and stays good, but the next release would have to be made anyway $certificate = Get-ChildItem Cert:\CurrentUser\My -CodeSigningCert | Sort-Object NotAfter -Descending | Select-Object -First 1 if (-not $certificate) { throw 'eSigner CKA loaded nothing into the certificate store. Does the account hold a code signing certificate — a document signature is a different thing and cannot sign a package.' } Write-Host "Signing as $($certificate.Subject), good until $($certificate.NotAfter.ToString('yyyy-MM-dd'))." "thumbprint=$($certificate.Thumbprint)" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8 "subject=$($certificate.Subject)" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8 # The package of the release carries the identity the app is known by and # the publisher of the certificate — Windows works out the family name of # a package from the two together, so an update replaces the installed # version only while both stay as they were. The names have no suffix: # that is what the app looks for when it checks for an update - name: Pack and sign the MSIX for the release env: IDENTITY_NAME: ${{ vars.MSIX_IDENTITY_NAME }} PUBLISHER: ${{ steps.certificate.outputs.subject }} PUBLISHER_DISPLAY_NAME: ${{ vars.MSIX_PUBLISHER_DISPLAY_NAME }} THUMBPRINT: ${{ steps.certificate.outputs.thumbprint }} run: | $ErrorActionPreference = 'Stop' $arguments = @{ Version = '${{ steps.version.outputs.version }}' OutputPath = 'artifacts/release' Publisher = $env:PUBLISHER CertificateThumbprint = $env:THUMBPRINT } $variables = @{ IdentityName = $env:IDENTITY_NAME PublisherDisplayName = $env:PUBLISHER_DISPLAY_NAME } foreach ($name in $variables.Keys) { if ($variables[$name]) { $arguments[$name] = $variables[$name] } } ./Packaging/build-msix.ps1 @arguments - name: Keep the signed packages uses: actions/upload-artifact@v4 with: name: msix-${{ steps.version.outputs.version }} path: artifacts/release/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 }