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.