4 Commits
Author SHA1 Message Date
alex 08192d317e fixed tests
Release / release (push) Successful in 8m58s
2026-08-11 16:34:28 +05:00
alex 3bf2018a62 fixed release version in pipeline
Release / release (push) Failing after 2m19s
2026-08-11 15:56:23 +05:00
alex cb39198e0a fixed pipeline
Release / release (push) Failing after 20s
2026-08-11 15:44:26 +05:00
alex a777b71b1a modified README.md
Release / release (push) Canceled after 2m15s
2026-08-11 15:02:04 +05:00
6 changed files with 220 additions and 55 deletions
+32 -4
View File
@@ -26,12 +26,40 @@ jobs:
runs-on: windows-x64 runs-on: windows-x64
steps: steps:
# lfs: true is not a nicety: the exe icon lives in Git LFS, and without it
# the checkout leaves a text pointer that the build cannot read as an icon
- name: Check out the sources - name: Check out the sources
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
lfs: true # The exe icon lives in Git LFS, and without it the checkout leaves a text
# pointer that the build cannot read as an icon.
#
# The objects are fetched here rather than by `lfs: true` on the checkout:
# that way they 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.<server>/.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 build breaks on the icon
$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 ', ')."
}
- name: Show the toolchain - name: Show the toolchain
run: dotnet --info run: dotnet --info
+46 -13
View File
@@ -1,5 +1,6 @@
# The release: a tag of the form v1.2.3.0 builds the solution, runs the tests # The release: a tag of the form v1.2.3 builds the solution, runs the tests and
# and packs the MSIX with the version taken from the tag. # packs the MSIX with the version taken from the tag — three numbers of the tag
# and a zero the Store keeps for itself.
# #
# The same requirements to the runner as in pull-request.yml apply: Windows, the # 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 # .NET 10 SDK and an interactive desktop session for the tests. makeappx comes
@@ -21,28 +22,60 @@ jobs:
runs-on: windows-x64 runs-on: windows-x64
steps: steps:
# lfs: true is not a nicety: the exe icon and the MSIX logos live in Git
# LFS, and without it the checkout leaves text pointers in their place —
# the build fails on the icon and the package would carry broken logos
- name: Check out the sources - name: Check out the sources
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
lfs: true
# The tag is the only place the version comes from: the Store takes four # The exe icon and the MSIX logos live in Git LFS, and without them the
# numbers ending in zero, so anything else is stopped here rather than # checkout leaves text pointers in their place — the build fails on the
# halfway through the packaging # 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.<server>/.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 - name: Read the version from the tag
id: version id: version
run: | run: |
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$tag = '${{ github.ref_name }}' $tag = '${{ github.ref_name }}'
if ($tag -notmatch '^v\d+\.\d+\.\d+\.0$') { if ($tag -notmatch '^v\d+\.\d+\.\d+$') {
throw "The tag '$tag' does not fit: a release is tagged as v1.2.3.0four numbers ending in zero." 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."
} }
"version=$($tag.Substring(1))" | Out-File $env:GITHUB_OUTPUT -Append -Encoding utf8 # 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
- name: Show the toolchain - name: Show the toolchain
run: dotnet --info run: dotnet --info
+39
View File
@@ -1,6 +1,8 @@
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.Reflection; using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
namespace CursorLang.Tests; namespace CursorLang.Tests;
@@ -14,6 +16,9 @@ namespace CursorLang.Tests;
/// ///
/// If the application is already running in this session, the checks skip /// If the application is already running in this session, the checks skip
/// themselves: meddling with someone else's running instance is not their business. /// themselves: meddling with someone else's running instance is not their business.
///
/// They skip themselves where there is no desktop to show a window on either — on a
/// build agent living as a Windows service, for one.
/// </remarks> /// </remarks>
public sealed class EndToEndTests public sealed class EndToEndTests
{ {
@@ -59,6 +64,16 @@ public sealed class EndToEndTests
/// <summary>A started application that shuts down together with the check.</summary> /// <summary>A started application that shuts down together with the check.</summary>
private sealed class Launch : IDisposable private sealed class Launch : IDisposable
{ {
private const int UOI_NAME = 2;
private const string InteractiveWindowStation = "WinSta0";
[DllImport("user32.dll")]
private static extern IntPtr GetProcessWindowStation();
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern bool GetUserObjectInformation(IntPtr hObj, int nIndex,
StringBuilder pvInfo, int nLength, out int lpnLengthNeeded);
private Launch(Process process) => Process = process; private Launch(Process process) => Process = process;
internal Process Process { get; } internal Process Process { get; }
@@ -66,6 +81,11 @@ public sealed class EndToEndTests
/// <summary>Starts the application first — making sure the place is free.</summary> /// <summary>Starts the application first — making sure the place is free.</summary>
internal static Launch Start() internal static Launch Start()
{ {
if (!HasInteractiveDesktop())
{
Assert.Skip("There is no interactive desktop here — the application has nowhere to show its window");
}
if (Process.GetProcessesByName("CursorLang").Length > 0) if (Process.GetProcessesByName("CursorLang").Length > 0)
{ {
Assert.Skip("The application is already running — this check keeps out of someone else's run"); Assert.Skip("The application is already running — this check keeps out of someone else's run");
@@ -116,6 +136,25 @@ public sealed class EndToEndTests
return IntPtr.Zero; return IntPtr.Zero;
} }
/// <summary>
/// Whether there is a desktop here to show a window on. A service gets a
/// window station of its own — "Service-0x0-3e7$" and the like: a window can
/// be created there, yet nothing shows it. Only "WinSta0" is the interactive one.
/// </summary>
private static bool HasInteractiveDesktop()
{
IntPtr station = GetProcessWindowStation();
if (station == IntPtr.Zero)
{
return false;
}
var name = new StringBuilder(256);
return GetUserObjectInformation(station, UOI_NAME, name, name.Capacity * sizeof(char), out _)
&& name.ToString().Equals(InteractiveWindowStation, StringComparison.OrdinalIgnoreCase);
}
private static string ExecutablePath() private static string ExecutablePath()
{ {
string configured = Assembly.GetExecutingAssembly() string configured = Assembly.GetExecutingAssembly()
@@ -124,9 +124,7 @@ public sealed class LayoutPopupWindowTests
{ {
var settings = new AppSettings var settings = new AppSettings
{ {
PlacementMode = PopupPlacementMode.FixedPoint, PlacementMode = PopupPlacementMode.FixedPoint, ScreenPosition = position, ScreenMargin = 24,
ScreenPosition = position,
ScreenMargin = 24,
}; };
using var popup = Popup.Create(settings); using var popup = Popup.Create(settings);
@@ -160,9 +158,7 @@ public sealed class LayoutPopupWindowTests
{ {
var settings = new AppSettings var settings = new AppSettings
{ {
PlacementMode = PopupPlacementMode.AtCursor, PlacementMode = PopupPlacementMode.AtCursor, CursorSide = AnchorSide.BottomRight, CursorOffset = 16,
CursorSide = AnchorSide.BottomRight,
CursorOffset = 16,
}; };
using var popup = Popup.Create(settings); using var popup = Popup.Create(settings);
@@ -213,23 +209,48 @@ public sealed class LayoutPopupWindowTests
{ {
var settings = new AppSettings var settings = new AppSettings
{ {
PlacementMode = PopupPlacementMode.AtCaret, PlacementMode = PopupPlacementMode.AtCaret, CaretSide = AnchorSide.BottomRight, CaretOffset = 8,
CaretSide = AnchorSide.BottomRight,
CaretOffset = 8,
}; };
using var popup = Popup.Create(settings); using var popup = Popup.Create(settings);
popup.ViewModel.ShortName = "RU"; popup.ViewModel.ShortName = "RU";
PopupWindowNative.Point before = PopupWindowNative.GetCursorPosition(); PopupWindowNative.Point before = default;
popup.Window.ShowPopup();
for (int attempt = 0; attempt < 10; attempt++)
{
if (CaretNative.TryGetCaretRect() is not null)
{
Assert.Skip("The foreground window reported a caret of its own");
}
before = PopupWindowNative.GetCursorPosition();
popup.Window.ShowPopup();
PopupWindowNative.Point after = PopupWindowNative.GetCursorPosition();
if (before.X == after.X && before.Y == after.Y)
{
break;
}
if (attempt == 9)
{
Assert.Skip("The cursor kept moving the whole time");
}
}
PopupWindowNative.Rect bounds = WindowPlacementNative.TryGetBounds(popup.Handle)!.Value; PopupWindowNative.Rect bounds = WindowPlacementNative.TryGetBounds(popup.Handle)!.Value;
double scale = PopupWindowNative.GetScaleAt(before);
// A gentle check: a window holding the input focus may still have a caret PopupWindowNative.Point expected = PopupLayout.NearAnchor(
Assert.True(bounds.Right > bounds.Left); PopupLayout.AsAnchor(before),
Assert.True(bounds.Bottom > bounds.Top); settings.CaretSide,
Assert.NotEqual(default, before); PopupLayout.ToPixels(settings.CaretOffset, scale),
bounds.Right - bounds.Left,
bounds.Bottom - bounds.Top);
Assert.Equal(expected.X, bounds.Left);
Assert.Equal(expected.Y, bounds.Top);
}); });
} }
@@ -266,8 +287,7 @@ public sealed class LayoutPopupWindowTests
{ {
var settings = new AppSettings var settings = new AppSettings
{ {
PlacementMode = PopupPlacementMode.FixedPoint, PlacementMode = PopupPlacementMode.FixedPoint, ScreenPosition = ScreenPosition.TopLeft,
ScreenPosition = ScreenPosition.TopLeft,
}; };
using var popup = Popup.Create(settings); using var popup = Popup.Create(settings);
+34 -11
View File
@@ -135,28 +135,51 @@ dotnet test --collect:"XPlat Code Coverage" --settings CursorLang.Tests\coverage
композиционный корень в `App.xaml.cs`, который вместо этого проверяется сквозным композиционный корень в `App.xaml.cs`, который вместо этого проверяется сквозным
запуском приложения. запуском приложения.
## Непрерывная сборка
Пайплайны лежат в `.gitea/workflows` и работают на Gitea Actions. Запрос
на слияние в `master` собирается и проверяется тестами; по тегу вида `v1.2.3`
проект собирается, проходит тесты, пакуется в MSIX и выкладывается релизом
с приложенными пакетами. Номер версии берётся только из тега — тег любого
другого вида останавливает прогон в самом начале. Версия пакета получается
`1.2.3.0`: Store принимает четыре числа и последнее оставляет себе, так что тег
на него не влияет.
Обоим пайплайнам нужен runner под Windows с меткой `windows-x64`, на нём —
.NET 10 SDK и git-lfs. Выгрузка исходников тянет файлы LFS: без них иконка
остаётся текстовой заглушкой и сборка на ней падает. Работать runner лучше
в интерактивном сеансе рабочего стола: тесты поднимают настоящие окна, и те
проверки, которым нужен свой рабочий стол — сквозные, а также те, что просят
окно на переднем плане или каретку, — на runner’е, живущем службой в нулевом
сеансе, пропускают себя: показать окно там негде.
Пакет, который несёт релиз, загружается в Partner Center как есть. Identity
берётся из переменных репозитория, а если те не заданы — из значений по
умолчанию в скрипте: `MSIX_IDENTITY_NAME`, `MSIX_PUBLISHER`
и `MSIX_PUBLISHER_DISPLAY_NAME`.
## Сборка пакета MSIX ## Сборка пакета MSIX
Ни Visual Studio, ни Windows SDK не требуются — `makeappx` и `signtool` Ни Visual Studio, ни Windows SDK не требуются — `makeappx` поставляется из
поставляются из пакета NuGet. Порядок публикации целиком — от регистрации пакета NuGet. Порядок публикации целиком — от регистрации разработчика
разработчика до отправки на проверку — описан в до отправки на проверку — описан в
[Packaging/PUBLISHING.RU.md](Packaging/PUBLISHING.RU.md). [Packaging/PUBLISHING.RU.md](Packaging/PUBLISHING.RU.md).
```powershell ```powershell
# Разово: отрисовать логотипы и иконку exe (уже в репозитории, повторить после правок) # Разово: отрисовать логотипы и иконку exe (уже в репозитории, повторить после правок)
powershell -File Packaging\New-Assets.ps1 powershell -File Packaging\New-Assets.ps1
# Сборка и подпись для локальной проверки # Проверка на своей машине: только своя архитектура
powershell -File Packaging\build-msix.ps1 -Architectures x64 -Sign powershell -File Packaging\build-msix.ps1 -Architectures x64
# Сборка для Store — identity берётся из Partner Center # Для Partner Center — identity та, что зарезервирована там
powershell -File Packaging\build-msix.ps1 -Version 1.0.1.0 ` powershell -File Packaging\build-msix.ps1 -Version 1.0.1.0 `
-IdentityName 12345Alrakis.CursorLang -Publisher "CN=ABCD1234-..." -IdentityName 12345AleksandrNeychev.CursorLang -Publisher "CN=ABCD1234-..."
``` ```
Результат — `artifacts\packages\CursorLang-<version>.msixbundle`, покрывающий x64 Результат — `artifacts\packages\CursorLang-<версия>.msixbundle`, покрывающий x64
и arm64. Загружать в Partner Center его нужно неподписанным — Store подписывает и arm64; рядом лежат пакеты отдельных архитектур. Bundle загружается в Partner
пакет собственным сертификатом. Center как есть.
Приложение поставляется с собственной копией .NET: Windows не включает .NET 10, а Приложение поставляется с собственной копией .NET: Windows не включает .NET 10, а
MSIX не может установить среду выполнения как зависимость пакета. MSIX не может установить среду выполнения как зависимость пакета.
@@ -166,5 +189,5 @@ MSIX не может установить среду выполнения как
```powershell ```powershell
Add-AppxPackage -Register artifacts\layout\x64\AppxManifest.xml Add-AppxPackage -Register artifacts\layout\x64\AppxManifest.xml
Remove-AppxPackage (Get-AppxPackage -Name Alrakis.CursorLang).PackageFullName Remove-AppxPackage (Get-AppxPackage -Name CursorLang).PackageFullName
``` ```
+32 -10
View File
@@ -133,28 +133,50 @@ What stays uncovered is what a test process cannot reach: the code paths that
require an MSIX package identity — `StartupTask` and the package data folder — require an MSIX package identity — `StartupTask` and the package data folder —
and the composition root in `App.xaml.cs`, which is exercised end-to-end instead. and the composition root in `App.xaml.cs`, which is exercised end-to-end instead.
## Continuous integration
The pipelines live in `.gitea/workflows` and run on Gitea Actions. A pull
request into `master` is built and tested; a tag of the form `v1.2.3` is built,
tested, packed into an MSIX and published as a release with the packages
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
text pointer and the build fails on it. The runner is better off working in an
interactive desktop session: the tests raise real windows, and the checks that
need a desktop of their own — the end-to-end ones, and those that ask for the
foreground window or the caret — skip themselves on a runner that lives as a
service in session 0, where there is no desktop to show a window on.
The package the release carries goes to Partner Center as it is. The identity
comes from repository variables and falls back to the defaults of the script when
unset: `MSIX_IDENTITY_NAME`, `MSIX_PUBLISHER` and `MSIX_PUBLISHER_DISPLAY_NAME`.
## Building the MSIX package ## Building the MSIX package
Neither Visual Studio nor the Windows SDK is required — `makeappx` and Neither Visual Studio nor the Windows SDK is required — `makeappx` comes from a
`signtool` come from a NuGet package. The full publishing procedure — from NuGet package. The full publishing procedure — from opening a developer account
opening a developer account to submitting for certification — is written up in to submitting for certification — is written up in
[Packaging/PUBLISHING.RU.md](Packaging/PUBLISHING.RU.md) (Russian only). [Packaging/PUBLISHING.RU.md](Packaging/PUBLISHING.RU.md) (Russian only).
```powershell ```powershell
# One-off: draw the logos and the exe icon (already committed, rerun after edits) # One-off: draw the logos and the exe icon (already committed, rerun after edits)
powershell -File Packaging\New-Assets.ps1 powershell -File Packaging\New-Assets.ps1
# Build and sign for local testing # A check on your own machine: your architecture alone
powershell -File Packaging\build-msix.ps1 -Architectures x64 -Sign powershell -File Packaging\build-msix.ps1 -Architectures x64
# Build for the Store — identity comes from Partner Center # For Partner Center — the identity is the one reserved there
powershell -File Packaging\build-msix.ps1 -Version 1.0.1.0 ` powershell -File Packaging\build-msix.ps1 -Version 1.0.1.0 `
-IdentityName 12345Alrakis.CursorLang -Publisher "CN=ABCD1234-..." -IdentityName 12345AleksandrNeychev.CursorLang -Publisher "CN=ABCD1234-..."
``` ```
The result is `artifacts\packages\CursorLang-<version>.msixbundle` covering x64 The result is `artifacts\packages\CursorLang-<version>.msixbundle` covering x64
and arm64. Upload it to Partner Center unsigned — the Store signs it with its own and arm64; next to it lie the packages of single architectures. Upload the bundle
certificate. to Partner Center as it is.
The app ships with its own copy of .NET: Windows does not include .NET 10, and 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. MSIX cannot install a runtime as a package dependency.
@@ -164,5 +186,5 @@ needs Developer Mode and no signature at all:
```powershell ```powershell
Add-AppxPackage -Register artifacts\layout\x64\AppxManifest.xml Add-AppxPackage -Register artifacts\layout\x64\AppxManifest.xml
Remove-AppxPackage (Get-AppxPackage -Name Alrakis.CursorLang).PackageFullName Remove-AppxPackage (Get-AppxPackage -Name CursorLang).PackageFullName
``` ```