modified release pipeline (#4)
Release / release (push) Canceled after 47s

await certificate from ssl.com

Reviewed-on: #4
Co-authored-by: Aleksandr Neychev <alexnejchev73@gmail.com>
This commit was merged in pull request #4.
This commit is contained in:
2026-08-13 11:17:22 +00:00
committed by alex
parent 5e11b16758
commit 42974ecc8f
34 changed files with 495 additions and 2091 deletions
@@ -1,304 +0,0 @@
using System.Net;
using System.Runtime.InteropServices;
using CursorLang.Core.Models;
using CursorLang.Core.Services;
using CursorLang.Tests.Shared;
namespace CursorLang.Core.Tests.Services;
/// <summary>
/// Reading the release list of Gitea. The answer of the server is not ours to
/// shape, so what matters is what the app makes of it.
/// </summary>
public sealed class GiteaReleaseFeedTests
{
private const string Releases = """
[
{
"tag_name": "v1.2.0",
"draft": false,
"prerelease": false,
"html_url": "https://git.alrakis.kz/alrakis/cursor-lang/releases/tag/v1.2.0",
"assets": [
{
"name": "CursorLang-1.2.0.0.msixbundle",
"browser_download_url": "https://git.alrakis.kz/attachments/CursorLang-1.2.0.0.msixbundle",
"size": 4096
}
]
}
]
""";
[Fact]
public async Task A_release_is_read_whole()
{
ReleaseInfo? release = await Read(Releases);
Assert.NotNull(release);
Assert.Equal(new Version(1, 2, 0, 0), release.Version);
Assert.Equal("v1.2.0", release.Tag);
Assert.Equal("https://git.alrakis.kz/alrakis/cursor-lang/releases/tag/v1.2.0", release.PageUrl?.ToString());
Assert.Equal("CursorLang-1.2.0.0.msixbundle", release.Package.FileName);
Assert.Equal(
"https://git.alrakis.kz/attachments/CursorLang-1.2.0.0.msixbundle",
release.Package.Url.ToString());
Assert.Equal(4096, release.Package.Size);
}
// The API of Gitea lives on the server itself, next to the pages of the
// repository
[Fact]
public async Task The_request_goes_to_the_releases_of_the_project()
{
var handler = FakeHttpHandler.Json(Releases);
var feed = new GiteaReleaseFeed(handler.CreateClient(), Options());
await feed.GetLatestAsync(TestContext.Current.CancellationToken);
Uri asked = Assert.Single(handler.Requests).RequestUri!;
Assert.Equal("git.alrakis.kz", asked.Host);
Assert.StartsWith(
"/api/v1/repos/alrakis/cursor-lang/releases", asked.AbsolutePath, StringComparison.Ordinal);
}
// A server sitting under a path of its own keeps that path: dropping it
// would send the request to a place that answers nothing
[Fact]
public async Task A_server_behind_a_path_keeps_it()
{
var handler = FakeHttpHandler.Json(Releases);
var feed = new GiteaReleaseFeed(
handler.CreateClient(),
new UpdateOptions { ServiceUri = new Uri("https://host.example.com/gitea"), Project = "team/app" });
await feed.GetLatestAsync(TestContext.Current.CancellationToken);
Uri asked = Assert.Single(handler.Requests).RequestUri!;
Assert.StartsWith("/gitea/api/v1/repos/team/app/releases", asked.AbsolutePath, StringComparison.Ordinal);
}
// «token» is the scheme of Gitea for keys of access
[Fact]
public void A_closed_repository_gets_the_token_it_asks_for()
{
var feed = new GiteaReleaseFeed(new HttpClient(), Options("secret"));
using var request = new HttpRequestMessage();
feed.Authorize(request);
Assert.Equal("token", request.Headers.Authorization?.Scheme);
Assert.Equal("secret", request.Headers.Authorization?.Parameter);
}
[Fact]
public void An_open_repository_is_asked_without_a_token()
{
var feed = new GiteaReleaseFeed(new HttpClient(), Options());
using var request = new HttpRequestMessage();
feed.Authorize(request);
Assert.Null(request.Headers.Authorization);
}
[Theory]
[InlineData("1.2.3", "1.2.3.0")]
[InlineData("v1.2.3", "1.2.3.0")]
[InlineData("V1.2", "1.2.0.0")]
[InlineData("1.2.3.4", "1.2.3.4")]
public async Task A_version_is_read_out_of_the_tag(string tag, string expected)
{
ReleaseInfo? release = await Read(WithTag(tag));
Assert.NotNull(release);
Assert.Equal(Version.Parse(expected), release.Version);
}
// A pre-release version is not something the app offers by itself:
// such a version is asked for on purpose
[Theory]
[InlineData("v1.2.3-beta")]
[InlineData("nightly")]
[InlineData("release-1")]
public async Task A_tag_that_is_not_a_version_is_passed_over(string tag)
{
Assert.Null(await Read(WithTag(tag)));
}
[Fact]
public async Task A_draft_and_a_pre_release_are_passed_over()
{
const string releases = """
[
{ "tag_name": "v3.0.0", "draft": true, "assets": [
{ "name": "a.msixbundle", "browser_download_url": "https://host/a.msixbundle" } ] },
{ "tag_name": "v2.0.0", "prerelease": true, "assets": [
{ "name": "b.msixbundle", "browser_download_url": "https://host/b.msixbundle" } ] },
{ "tag_name": "v1.0.0", "assets": [
{ "name": "c.msixbundle", "browser_download_url": "https://host/c.msixbundle" } ] }
]
""";
ReleaseInfo? release = await Read(releases);
Assert.NotNull(release);
Assert.Equal(new Version(1, 0, 0, 0), release.Version);
}
// The order of the releases belongs to the server, the highest number to
// the app: a fix to an older branch can be the freshest release
[Fact]
public async Task The_highest_version_wins_over_the_order_of_the_answer()
{
const string releases = """
[
{ "tag_name": "v1.0.5", "assets": [
{ "name": "a.msixbundle", "browser_download_url": "https://host/a.msixbundle" } ] },
{ "tag_name": "v2.0.0", "assets": [
{ "name": "b.msixbundle", "browser_download_url": "https://host/b.msixbundle" } ] }
]
""";
ReleaseInfo? release = await Read(releases);
Assert.NotNull(release);
Assert.Equal(new Version(2, 0, 0, 0), release.Version);
}
[Fact]
public async Task A_release_without_a_package_is_passed_over()
{
const string releases = """
[
{ "tag_name": "v2.0.0", "assets": [
{ "name": "notes.txt", "browser_download_url": "https://host/notes.txt" } ] },
{ "tag_name": "v1.0.0", "assets": [
{ "name": "a.msixbundle", "browser_download_url": "https://host/a.msixbundle" } ] }
]
""";
ReleaseInfo? release = await Read(releases);
Assert.NotNull(release);
Assert.Equal(new Version(1, 0, 0, 0), release.Version);
}
// The signature is what Windows checks, but a package offered over an open
// connection is not worth downloading in the first place
[Fact]
public async Task A_package_offered_over_an_open_connection_is_passed_over()
{
const string releases = """
[
{ "tag_name": "v2.0.0", "assets": [
{ "name": "a.msixbundle", "browser_download_url": "http://host/a.msixbundle" } ] }
]
""";
Assert.Null(await Read(releases));
}
[Fact]
public async Task A_bundle_wins_over_the_packages_of_single_architectures()
{
const string releases = """
[
{ "tag_name": "v2.0.0", "assets": [
{ "name": "CursorLang-2.0.0.0-x64.msix", "browser_download_url": "https://host/x64.msix" },
{ "name": "CursorLang-2.0.0.0.msixbundle", "browser_download_url": "https://host/all.msixbundle" } ] }
]
""";
ReleaseInfo? release = await Read(releases);
Assert.NotNull(release);
Assert.Equal("https://host/all.msixbundle", release.Package.Url.ToString());
}
[Fact]
public async Task Out_of_several_packages_the_one_for_this_machine_is_taken()
{
const string releases = """
[
{ "tag_name": "v2.0.0", "assets": [
{ "name": "CursorLang-2.0.0.0-arm64.msix", "browser_download_url": "https://host/arm64.msix" },
{ "name": "CursorLang-2.0.0.0-x64.msix", "browser_download_url": "https://host/x64.msix" } ] }
]
""";
string expected = RuntimeInformation.ProcessArchitecture == Architecture.Arm64
? "https://host/arm64.msix"
: "https://host/x64.msix";
ReleaseInfo? release = await Read(releases);
Assert.NotNull(release);
Assert.Equal(expected, release.Package.Url.ToString());
}
// Without the architecture in the name there is no telling which package is
// for this machine — unless it is the only one there
[Fact]
public async Task A_package_without_an_architecture_is_taken_only_when_alone()
{
const string alone = """
[
{ "tag_name": "v2.0.0", "assets": [
{ "name": "CursorLang.msix", "browser_download_url": "https://host/one.msix" } ] }
]
""";
const string ambiguous = """
[
{ "tag_name": "v2.0.0", "assets": [
{ "name": "CursorLang.msix", "browser_download_url": "https://host/one.msix" },
{ "name": "CursorLang-other.msix", "browser_download_url": "https://host/other.msix" } ] }
]
""";
Assert.NotNull(await Read(alone));
Assert.Null(await Read(ambiguous));
}
[Fact]
public async Task An_empty_list_of_releases_means_nothing_to_offer()
{
Assert.Null(await Read("[]"));
}
// A server answering with something else is no reason to fail
[Fact]
public async Task An_answer_that_is_not_a_list_leaves_the_app_with_nothing()
{
Assert.Null(await Read("""{ "message": "Not Found" }"""));
}
[Fact]
public async Task A_refusal_of_the_server_is_raised()
{
var handler = FakeHttpHandler.Status(HttpStatusCode.Unauthorized);
var feed = new GiteaReleaseFeed(handler.CreateClient(), Options());
await Assert.ThrowsAsync<HttpRequestException>(
() => feed.GetLatestAsync(TestContext.Current.CancellationToken));
}
private static string WithTag(string tag) => $$"""
[
{ "tag_name": "{{tag}}", "assets": [
{ "name": "CursorLang.msixbundle", "browser_download_url": "https://host/a.msixbundle" } ] }
]
""";
private static UpdateOptions Options(string? token = null) => new()
{
ServiceUri = new Uri("https://git.alrakis.kz/"),
Project = "alrakis/cursor-lang",
AccessToken = token,
};
private static Task<ReleaseInfo?> Read(string json) =>
new GiteaReleaseFeed(FakeHttpHandler.Json(json).CreateClient(), Options())
.GetLatestAsync(TestContext.Current.CancellationToken);
}
@@ -1,32 +0,0 @@
using CursorLang.Core.Services;
namespace CursorLang.Core.Tests.Services;
/// <summary>
/// Where the app looks for its releases. The values belong to the build, and a
/// wrong one shows only as an update that never arrives.
/// </summary>
public sealed class UpdateOptionsTests
{
[Fact]
public void Out_of_the_box_the_releases_are_looked_for_in_the_repository_of_the_app()
{
var options = new UpdateOptions();
Assert.Equal("git.alrakis.kz", options.ServiceUri.Host);
Assert.Equal("alrakis/cursor-lang", options.Project);
}
[Fact]
public void Another_server_is_taken_as_it_is_given()
{
var options = new UpdateOptions
{
ServiceUri = new Uri("https://git.example.com/"),
Project = "team/app",
};
Assert.Equal("git.example.com", options.ServiceUri.Host);
Assert.Equal("team/app", options.Project);
}
}
@@ -1,156 +0,0 @@
using System.Net;
using System.Text;
using CursorLang.Core.Models;
using CursorLang.Core.Services;
using CursorLang.Tests.Shared;
namespace CursorLang.Core.Tests.Services;
/// <summary>
/// What the app does with a release once it has found one: whether it is newer
/// at all, and what ends up on disk.
/// </summary>
public sealed class UpdateServiceTests
{
[Theory]
[InlineData("1.0.0.0", "1.0.1.0", true)]
[InlineData("1.0.0.0", "2.0.0.0", true)]
[InlineData("1.0.0.0", "1.0.0.0", false)]
[InlineData("1.0.1.0", "1.0.0.0", false)]
public async Task Only_a_higher_version_counts_as_an_update(string current, string found, bool offered)
{
var feed = new FakeReleaseFeed { Release = Release(found) };
using TempFolder folder = new();
using UpdateService service = Create(feed, folder, current);
ReleaseInfo? update = await service.CheckAsync(TestContext.Current.CancellationToken);
Assert.Equal(offered, update is not null);
}
[Fact]
public async Task An_empty_repository_leaves_the_app_with_nothing()
{
var feed = new FakeReleaseFeed { Release = null };
using TempFolder folder = new();
using UpdateService service = Create(feed, folder);
Assert.Null(await service.CheckAsync(TestContext.Current.CancellationToken));
}
[Fact]
public async Task The_package_ends_up_on_disk_whole()
{
byte[] content = Encoding.UTF8.GetBytes(new string('p', 300_000));
using TempFolder folder = new();
using UpdateService service = Create(new FakeReleaseFeed(), folder, client: FakeHttpHandler.Bytes(content));
string path = await service.DownloadAsync(Release("2.0.0.0"), null, TestContext.Current.CancellationToken);
Assert.Equal(content, await File.ReadAllBytesAsync(path, TestContext.Current.CancellationToken));
}
// The name comes from the version, not from the answer: the app creates a
// file with it, and the answer comes from the other side
[Fact]
public async Task The_name_of_the_file_is_built_by_the_app_itself()
{
using TempFolder folder = new();
using UpdateService service = Create(new FakeReleaseFeed(), folder, client: FakeHttpHandler.Bytes([1, 2, 3]));
var release = new ReleaseInfo(
new Version(2, 0, 0, 0),
"v2.0.0",
null,
new ReleaseAsset(@"..\..\evil.msixbundle", new Uri("https://host/a"), 3));
string path = await service.DownloadAsync(release, null, TestContext.Current.CancellationToken);
Assert.Equal("CursorLang-2.0.0.0.msixbundle", Path.GetFileName(path));
Assert.Equal(folder.Path, Path.GetDirectoryName(path));
}
[Fact]
public async Task The_download_reports_how_far_it_has_come()
{
byte[] content = new byte[500_000];
using TempFolder folder = new();
using UpdateService service = Create(new FakeReleaseFeed(), folder, client: FakeHttpHandler.Bytes(content));
var reported = new CollectingProgress();
await service.DownloadAsync(Release("2.0.0.0"), reported, TestContext.Current.CancellationToken);
Assert.NotEmpty(reported.Values);
Assert.All(reported.Values, value => Assert.InRange(value, 0, 1));
Assert.Equal(reported.Values, [.. reported.Values.Order()]);
Assert.Equal(1, reported.Values[^1]);
}
[Fact]
public async Task A_closed_repository_gets_the_token_with_the_download_too()
{
var feed = new FakeReleaseFeed();
using TempFolder folder = new();
using UpdateService service = Create(feed, folder, client: FakeHttpHandler.Bytes([1]));
await service.DownloadAsync(Release("2.0.0.0"), null, TestContext.Current.CancellationToken);
Assert.Single(feed.Authorized);
}
[Fact]
public async Task A_refusal_of_the_hosting_service_leaves_no_package_behind()
{
using TempFolder folder = new();
using UpdateService service = Create(
new FakeReleaseFeed(), folder, client: FakeHttpHandler.Status(HttpStatusCode.NotFound));
await Assert.ThrowsAsync<HttpRequestException>(
() => service.DownloadAsync(Release("2.0.0.0"), null, TestContext.Current.CancellationToken));
Assert.Empty(Directory.GetFiles(folder.Path));
}
// A package left from an earlier download takes up room and is of no use
// once it has been installed
[Fact]
public async Task An_older_download_is_cleared_away()
{
using TempFolder folder = new();
string leftover = folder.File("CursorLang-1.5.0.0.msixbundle");
await File.WriteAllTextAsync(leftover, "old", TestContext.Current.CancellationToken);
using UpdateService service = Create(new FakeReleaseFeed(), folder, client: FakeHttpHandler.Bytes([1]));
string path = await service.DownloadAsync(Release("2.0.0.0"), null, TestContext.Current.CancellationToken);
Assert.False(File.Exists(leftover));
Assert.True(File.Exists(path));
}
/// <summary>
/// The reports as the download makes them. <c>Progress&lt;T&gt;</c> would
/// hand them over to another thread, and a test has nowhere to wait for that.
/// </summary>
private sealed class CollectingProgress : IProgress<double>
{
internal List<double> Values { get; } = [];
public void Report(double value) => Values.Add(value);
}
private static ReleaseInfo Release(string version) => new(
Version.Parse(version),
$"v{version}",
new Uri("https://host/releases/tag"),
new ReleaseAsset($"CursorLang-{version}.msixbundle", new Uri("https://host/package"), 0));
private static UpdateService Create(
IReleaseFeed feed,
TempFolder folder,
string current = "1.0.0.0",
FakeHttpHandler? client = null) =>
new(feed,
(client ?? FakeHttpHandler.Bytes([])).CreateClient(),
Version.Parse(current),
folder.Path);
}