From 2d86f1a7d5769a4cd3fbc71285989e57956adfbe Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Sat, 22 Aug 2026 10:47:27 +0200 Subject: [PATCH 1/2] Build, test and package on Windows, Linux and macOS in CI Nothing built the solution outside a developer machine, and nothing produced a runnable artifact. One workflow now does both on each OS's native runner: a zip for win-x64 and linux-x64, an unsigned, unnotarized dmg for osx-arm64. The publish is framework-dependent on purpose. The app needs an installed .NET SDK at runtime regardless - Roslyn's MSBuild workspace, MSBuildLocator, dotnet build and test - so a bundled runtime would only grow the artifact. The .app is assembled by an MSBuild target rather than in the workflow, so a plain dotnet publish -r osx-arm64 reproduces what CI ships. deb/rpm, a Homebrew cask, signing and versioned artifact names, which ILSpy's pipeline carries, are left out until there is a release process to need them. Assisted-by: Claude:claude-fable-5:Claude Code --- .github/workflows/build.yml | 66 +++++++++++++++++++++++++++ src/Stampeded/Assets/macos/Info.plist | 26 +++++++++++ src/Stampeded/Stampeded.csproj | 18 ++++++++ 3 files changed, 110 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 src/Stampeded/Assets/macos/Info.plist diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..fb1befa --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,66 @@ +name: Build + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + rid: win-x64 + - os: ubuntu-latest + rid: linux-x64 + - os: macos-latest + rid: osx-arm64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: 10.0.x + + - run: dotnet build Stampeded.slnx -c Release + - run: dotnet test Stampeded.slnx -c Release --no-build + + # Framework-dependent on purpose: the app needs an installed .NET SDK at runtime + # anyway (Roslyn's MSBuild workspace, dotnet build/test), so bundling a runtime + # would only grow the artifact. RID-specific so an apphost exists to launch. + - run: dotnet publish src/Stampeded/Stampeded.csproj -c Release -r ${{ matrix.rid }} --no-self-contained -o publish/${{ matrix.rid }} + + - name: Zip (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: Compress-Archive -Path publish/win-x64/* -DestinationPath Stampeded-win-x64.zip + + # Info-ZIP keeps the execute bit; Compress-Archive and upload-artifact drop it. + - name: Zip (Linux) + if: runner.os == 'Linux' + shell: bash + run: cd publish/linux-x64 && zip -r -q ../../Stampeded-linux-x64.zip . + + # Unsigned and unnotarized: first launch needs `xattr -dr com.apple.quarantine Stampeded.app`. + - name: dmg (macOS) + if: runner.os == 'macOS' + shell: bash + run: | + mkdir dmg + cp -R publish/osx-arm64/Stampeded.app dmg/ + ln -s /Applications dmg/Applications + hdiutil create -volname Stampeded -srcfolder dmg -ov -format UDZO Stampeded-macos-arm64.dmg + + - uses: actions/upload-artifact@v7 + with: + name: Stampeded-${{ matrix.rid }} + path: | + Stampeded-*.zip + Stampeded-*.dmg + if-no-files-found: error diff --git a/src/Stampeded/Assets/macos/Info.plist b/src/Stampeded/Assets/macos/Info.plist new file mode 100644 index 0000000..e4bba71 --- /dev/null +++ b/src/Stampeded/Assets/macos/Info.plist @@ -0,0 +1,26 @@ + + + + + CFBundleName + Stampeded + CFBundleDisplayName + Stampeded + CFBundleIdentifier + net.icsharpcode.stampeded + CFBundleVersion + 0.1.0 + CFBundleShortVersionString + 0.1.0 + CFBundlePackageType + APPL + CFBundleExecutable + Stampeded + LSMinimumSystemVersion + 11.0 + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + + diff --git a/src/Stampeded/Stampeded.csproj b/src/Stampeded/Stampeded.csproj index 5ae27ef..54617e9 100644 --- a/src/Stampeded/Stampeded.csproj +++ b/src/Stampeded/Stampeded.csproj @@ -24,4 +24,22 @@ + + + + <_MacBundleContents>$(PublishDir)$(AssemblyName).app/Contents + + + <_MacPublishFiles Include="$(PublishDir)**/*" Exclude="$(PublishDir)$(AssemblyName).app/**" /> + + + + + + + From 96548e4028c5e69a6cb4cf5933a8df0ddf80eb11 Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Sat, 22 Aug 2026 10:58:08 +0200 Subject: [PATCH 2/2] Make the tests pass on Windows and macOS runners Three things the suite assumed about its host turned out to be Linux-only. git writes loose objects read-only, and on Windows Directory.Delete refuses a read-only file, so every fixture's TearDown threw past the IOException it caught. One helper clears the flag first and treats cleanup as best effort; a leaked temp directory is not a verdict on what the test asserted. git prints worktree paths in its own form - forward slashes on Windows, the symlink-resolved /private/var on macOS - and the service passes that form on unchanged. The three assertions that compared it with the path the test built now ask git how it spells that path instead of assuming it matches. A raw string literal carries the line endings of the checkout, which with autocrlf is CRLF on Windows; the diff builder speaks LF. The literals are normalised once instead of the repository dictating line endings for everyone. Assisted-by: Claude:claude-fable-5:Claude Code --- .../BuildArtifactCleanupTests.cs | 8 +---- .../DecompilationServiceTests.cs | 2 +- tests/Stampeded.Core.Tests/DraftEditTests.cs | 8 ++--- .../EnclosingMemberTests.cs | 4 +-- .../GeneratedSourcesTests.cs | 8 +---- .../GitBlobReaderTests.cs | 8 +---- .../Stampeded.Core.Tests/GitInterdiffTests.cs | 8 +---- .../GitMergeStateTests.cs | 21 +++++++------- tests/Stampeded.Core.Tests/GitPushTests.cs | 8 +---- tests/Stampeded.Core.Tests/GitRebaseTests.cs | 18 ++++++------ tests/Stampeded.Core.Tests/ReReviewTests.cs | 6 ++-- .../SideBySideBuilderTests.cs | 9 +++--- .../SolutionTargetTests.cs | 2 +- tests/Stampeded.Core.Tests/TempDirectory.cs | 29 +++++++++++++++++++ .../WorkspacePathTests.cs | 6 ++-- .../WorktreeCacheTests.cs | 8 +---- 16 files changed, 74 insertions(+), 79 deletions(-) create mode 100644 tests/Stampeded.Core.Tests/TempDirectory.cs diff --git a/tests/Stampeded.Core.Tests/BuildArtifactCleanupTests.cs b/tests/Stampeded.Core.Tests/BuildArtifactCleanupTests.cs index 4858646..74a5827 100644 --- a/tests/Stampeded.Core.Tests/BuildArtifactCleanupTests.cs +++ b/tests/Stampeded.Core.Tests/BuildArtifactCleanupTests.cs @@ -18,13 +18,7 @@ public void SetUp() [TearDown] public void TearDown() { - try - { - Directory.Delete(root, recursive: true); - } - catch (IOException) - { - } + TempDirectory.Delete(root); } static void WriteFile(string directory, string name) diff --git a/tests/Stampeded.Core.Tests/DecompilationServiceTests.cs b/tests/Stampeded.Core.Tests/DecompilationServiceTests.cs index 5a0e0e8..a71db5a 100644 --- a/tests/Stampeded.Core.Tests/DecompilationServiceTests.cs +++ b/tests/Stampeded.Core.Tests/DecompilationServiceTests.cs @@ -72,7 +72,7 @@ Third line. } finally { - Directory.Delete(dir, recursive: true); + TempDirectory.Delete(dir); } } } diff --git a/tests/Stampeded.Core.Tests/DraftEditTests.cs b/tests/Stampeded.Core.Tests/DraftEditTests.cs index 6c7ec02..3995bf8 100644 --- a/tests/Stampeded.Core.Tests/DraftEditTests.cs +++ b/tests/Stampeded.Core.Tests/DraftEditTests.cs @@ -31,7 +31,7 @@ public void RewritesADraftInPlace() } finally { - Directory.Delete(dir, recursive: true); + TempDirectory.Delete(dir); } } @@ -54,7 +54,7 @@ public void SurvivesReopeningTheReview() } finally { - Directory.Delete(dir, recursive: true); + TempDirectory.Delete(dir); } } @@ -74,7 +74,7 @@ public void IgnoresADraftThatIsNotThere() } finally { - Directory.Delete(dir, recursive: true); + TempDirectory.Delete(dir); } } @@ -99,7 +99,7 @@ public void AReplyKeepsTheThreadItAnswersAcrossSessions() } finally { - Directory.Delete(dir, recursive: true); + TempDirectory.Delete(dir); } } } diff --git a/tests/Stampeded.Core.Tests/EnclosingMemberTests.cs b/tests/Stampeded.Core.Tests/EnclosingMemberTests.cs index 1df43e2..659a8a2 100644 --- a/tests/Stampeded.Core.Tests/EnclosingMemberTests.cs +++ b/tests/Stampeded.Core.Tests/EnclosingMemberTests.cs @@ -61,7 +61,7 @@ public class TypeInferenceTests } finally { - Directory.Delete(dir, recursive: true); + TempDirectory.Delete(dir); } } @@ -98,7 +98,7 @@ public void Dispose() { } } finally { - Directory.Delete(dir, recursive: true); + TempDirectory.Delete(dir); } } diff --git a/tests/Stampeded.Core.Tests/GeneratedSourcesTests.cs b/tests/Stampeded.Core.Tests/GeneratedSourcesTests.cs index 2405de3..b7eb357 100644 --- a/tests/Stampeded.Core.Tests/GeneratedSourcesTests.cs +++ b/tests/Stampeded.Core.Tests/GeneratedSourcesTests.cs @@ -19,13 +19,7 @@ public void RemoveTemporaryDirectories() { foreach (var dir in temporaryDirectories) { - try - { - Directory.Delete(dir, recursive: true); - } - catch (IOException) - { - } + TempDirectory.Delete(dir); } temporaryDirectories.Clear(); } diff --git a/tests/Stampeded.Core.Tests/GitBlobReaderTests.cs b/tests/Stampeded.Core.Tests/GitBlobReaderTests.cs index 553fef9..e48ecf9 100644 --- a/tests/Stampeded.Core.Tests/GitBlobReaderTests.cs +++ b/tests/Stampeded.Core.Tests/GitBlobReaderTests.cs @@ -28,13 +28,7 @@ public void RemoveTemporaryDirectories() { foreach (var dir in temporaryDirectories) { - try - { - Directory.Delete(dir, recursive: true); - } - catch (IOException) - { - } + TempDirectory.Delete(dir); } temporaryDirectories.Clear(); } diff --git a/tests/Stampeded.Core.Tests/GitInterdiffTests.cs b/tests/Stampeded.Core.Tests/GitInterdiffTests.cs index 5dd89a3..e3a9ad9 100644 --- a/tests/Stampeded.Core.Tests/GitInterdiffTests.cs +++ b/tests/Stampeded.Core.Tests/GitInterdiffTests.cs @@ -30,13 +30,7 @@ public void RemoveTemporaryDirectories() { foreach (var dir in temporaryDirectories) { - try - { - Directory.Delete(dir, recursive: true); - } - catch (IOException) - { - } + TempDirectory.Delete(dir); } temporaryDirectories.Clear(); } diff --git a/tests/Stampeded.Core.Tests/GitMergeStateTests.cs b/tests/Stampeded.Core.Tests/GitMergeStateTests.cs index a7baa19..fb68a72 100644 --- a/tests/Stampeded.Core.Tests/GitMergeStateTests.cs +++ b/tests/Stampeded.Core.Tests/GitMergeStateTests.cs @@ -30,13 +30,7 @@ public void RemoveTemporaryDirectories() { foreach (var dir in temporaryDirectories) { - try - { - Directory.Delete(dir, recursive: true); - } - catch (IOException) - { - } + TempDirectory.Delete(dir); } temporaryDirectories.Clear(); } @@ -160,10 +154,11 @@ public async Task RemovesTheWorktreeThatHoldsTheBranch() await Git("branch", "already-in"); string worktree = NewDirectory(); await Git("worktree", "add", "--quiet", worktree, "already-in"); + string worktreeAsGitReportsIt = await AsGitReports(worktree); var deletion = await new GitService(repo).DeleteBranchAsync("already-in"); - Assert.That(deletion.RemovedWorktree, Is.EqualTo(worktree)); + Assert.That(deletion.RemovedWorktree, Is.EqualTo(worktreeAsGitReportsIt)); Assert.That(Directory.Exists(worktree), Is.False); Assert.That(await Git("branch", "--format=%(refname:short)"), Does.Not.Contain("already-in")); } @@ -205,13 +200,14 @@ public async Task RemovesAWorktreeWithSubmodulesThatGitRefusesToTouch() await Git("branch", "already-in"); string worktree = NewDirectory(); await Git("worktree", "add", "--quiet", worktree, "already-in"); + string worktreeAsGitReportsIt = await AsGitReports(worktree); var deletion = await new GitService(repo).DeleteBranchAsync("already-in"); - Assert.That(deletion.RemovedWorktree, Is.EqualTo(worktree)); + Assert.That(deletion.RemovedWorktree, Is.EqualTo(worktreeAsGitReportsIt)); Assert.That(Directory.Exists(worktree), Is.False); Assert.That(await Git("branch", "--format=%(refname:short)"), Does.Not.Contain("already-in")); - Assert.That(await Git("worktree", "list", "--porcelain"), Does.Not.Contain(worktree), + Assert.That(await Git("worktree", "list", "--porcelain"), Does.Not.Contain(worktreeAsGitReportsIt), "the administrative entry has to go with the directory"); } @@ -273,4 +269,9 @@ async Task Commit(string fileName, string content) } Task Git(params string[] args) => ExternalTool.RunAsync("git", args, repo); + + /// The path in the form git prints it - forward slashes on Windows, symlinks + /// resolved on macOS - which is the form the service passes on unchanged. + static async Task AsGitReports(string dir) + => (await ExternalTool.RunAsync("git", ["rev-parse", "--show-toplevel"], dir)).Trim(); } diff --git a/tests/Stampeded.Core.Tests/GitPushTests.cs b/tests/Stampeded.Core.Tests/GitPushTests.cs index 76900cf..58c5023 100644 --- a/tests/Stampeded.Core.Tests/GitPushTests.cs +++ b/tests/Stampeded.Core.Tests/GitPushTests.cs @@ -35,13 +35,7 @@ public void RemoveTemporaryDirectories() { foreach (var dir in temporaryDirectories) { - try - { - Directory.Delete(dir, recursive: true); - } - catch (IOException) - { - } + TempDirectory.Delete(dir); } temporaryDirectories.Clear(); } diff --git a/tests/Stampeded.Core.Tests/GitRebaseTests.cs b/tests/Stampeded.Core.Tests/GitRebaseTests.cs index 3fdcce8..a440e4e 100644 --- a/tests/Stampeded.Core.Tests/GitRebaseTests.cs +++ b/tests/Stampeded.Core.Tests/GitRebaseTests.cs @@ -36,13 +36,7 @@ public void RemoveTemporaryDirectories() { foreach (var dir in temporaryDirectories) { - try - { - Directory.Delete(dir, recursive: true); - } - catch (IOException) - { - } + TempDirectory.Delete(dir); } temporaryDirectories.Clear(); } @@ -67,6 +61,7 @@ public async Task RebasesABranchThatIsCheckedOutInAWorktree() { string worktree = NewDirectory(); await Git(repo, "worktree", "add", "--quiet", worktree, "topic"); + string worktreeAsGitReportsIt = await AsGitReports(worktree); var git = new GitService(repo); var result = await git.RebaseBranchAsync("topic", "main"); @@ -75,14 +70,14 @@ public async Task RebasesABranchThatIsCheckedOutInAWorktree() "topic should now sit on top of main"); // The checkout that holds the branch has to move with it, or its index and working // tree describe a commit the branch no longer points at. - Assert.That(result.Checkout, Is.EqualTo(worktree)); + Assert.That(result.Checkout, Is.EqualTo(worktreeAsGitReportsIt)); Assert.That((await Git(worktree, "rev-parse", "HEAD")).Trim(), Is.EqualTo(await RevParse("topic"))); Assert.That((await Git(worktree, "status", "--porcelain")).Trim(), Is.Empty); Assert.That(File.Exists(Path.Combine(worktree, "main.txt")), Is.True, "the rebased checkout should have main's file"); // The recovery the UI offers has to work here, and git branch -f would be refused. - Assert.That(result.RecoveryCommand("topic"), Is.EqualTo($"git -C {worktree} reset --hard {result.Before[..9]}")); + Assert.That(result.RecoveryCommand("topic"), Is.EqualTo($"git -C {worktreeAsGitReportsIt} reset --hard {result.Before[..9]}")); await Git(worktree, "reset", "--hard", result.Before); Assert.That(await RevParse("topic"), Is.EqualTo(result.Before)); } @@ -176,6 +171,11 @@ async Task Commit(string fileName, string content) Task Git(string dir, params string[] args) => ExternalTool.RunAsync("git", args, dir); + /// The path in the form git prints it - forward slashes on Windows, symlinks + /// resolved on macOS - which is the form the service passes on unchanged. + static async Task AsGitReports(string dir) + => (await ExternalTool.RunAsync("git", ["rev-parse", "--show-toplevel"], dir)).Trim(); + async Task RevParse(string reference) => (await Git(repo, "rev-parse", reference)).Trim(); async Task MergeBase(string a, string b) => (await Git(repo, "merge-base", a, b)).Trim(); diff --git a/tests/Stampeded.Core.Tests/ReReviewTests.cs b/tests/Stampeded.Core.Tests/ReReviewTests.cs index d922973..344d878 100644 --- a/tests/Stampeded.Core.Tests/ReReviewTests.cs +++ b/tests/Stampeded.Core.Tests/ReReviewTests.cs @@ -43,7 +43,7 @@ public void StoreCapturesSupersededStateOnHeadMove() } finally { - Directory.Delete(dir, recursive: true); + TempDirectory.Delete(dir); } } @@ -73,7 +73,7 @@ public void ThePreviousHeadOutlivesTheOpenThatDiscoveredTheMove() } finally { - Directory.Delete(dir, recursive: true); + TempDirectory.Delete(dir); } } @@ -101,7 +101,7 @@ public void StateWrittenBeforeTheBaselineExistedStillLoads() } finally { - Directory.Delete(dir, recursive: true); + TempDirectory.Delete(dir); } } } diff --git a/tests/Stampeded.Core.Tests/SideBySideBuilderTests.cs b/tests/Stampeded.Core.Tests/SideBySideBuilderTests.cs index 204921e..401b5b8 100644 --- a/tests/Stampeded.Core.Tests/SideBySideBuilderTests.cs +++ b/tests/Stampeded.Core.Tests/SideBySideBuilderTests.cs @@ -7,20 +7,21 @@ namespace Stampeded.Core.Tests; [TestFixture] public class SideBySideBuilderTests { - const string OldText = """ + // Raw literals take the line endings of the checkout; the builder speaks LF. + static readonly string OldText = """ line a line b line c line d - """; + """.ReplaceLineEndings("\n"); - const string NewText = """ + static readonly string NewText = """ line a line b CHANGED line c inserted line line d - """; + """.ReplaceLineEndings("\n"); [Test] public void SidesHaveEqualLineCounts() diff --git a/tests/Stampeded.Core.Tests/SolutionTargetTests.cs b/tests/Stampeded.Core.Tests/SolutionTargetTests.cs index 66a4646..aaad564 100644 --- a/tests/Stampeded.Core.Tests/SolutionTargetTests.cs +++ b/tests/Stampeded.Core.Tests/SolutionTargetTests.cs @@ -21,7 +21,7 @@ public void SetUp() public void TearDown() { if (Directory.Exists(root)) - Directory.Delete(root, recursive: true); + TempDirectory.Delete(root); } void Write(string name, int size) => File.WriteAllText(Path.Combine(root, name), new string('x', size)); diff --git a/tests/Stampeded.Core.Tests/TempDirectory.cs b/tests/Stampeded.Core.Tests/TempDirectory.cs new file mode 100644 index 0000000..b369c52 --- /dev/null +++ b/tests/Stampeded.Core.Tests/TempDirectory.cs @@ -0,0 +1,29 @@ +namespace Stampeded.Core.Tests; + +/// Removes a directory a test created under the temp folder. git writes its loose +/// objects read-only, and on Windows Directory.Delete refuses a read-only file, so the flag is +/// cleared first. Removal is best effort: a directory that cannot go is a leak in the temp +/// folder, not a verdict on what the test asserted. +static class TempDirectory +{ + public static void Delete(string dir) + { + if (!Directory.Exists(dir)) + return; + try + { + // Reparse points are skipped so a symlink a test planted is not followed out of + // the directory being cleaned. + var options = new EnumerationOptions { RecurseSubdirectories = true, AttributesToSkip = FileAttributes.ReparsePoint }; + foreach (var file in Directory.EnumerateFiles(dir, "*", options)) + File.SetAttributes(file, FileAttributes.Normal); + Directory.Delete(dir, recursive: true); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } +} diff --git a/tests/Stampeded.Core.Tests/WorkspacePathTests.cs b/tests/Stampeded.Core.Tests/WorkspacePathTests.cs index ceed746..089e409 100644 --- a/tests/Stampeded.Core.Tests/WorkspacePathTests.cs +++ b/tests/Stampeded.Core.Tests/WorkspacePathTests.cs @@ -31,7 +31,7 @@ public async Task RoundTripsAPathWithTheSeparatorsGitUses() } finally { - Directory.Delete(dir, recursive: true); + TempDirectory.Delete(dir); } } @@ -53,8 +53,8 @@ public async Task RejectsAPathOutsideTheWorktree() } finally { - Directory.Delete(root, recursive: true); - Directory.Delete(sibling, recursive: true); + TempDirectory.Delete(root); + TempDirectory.Delete(sibling); } } } diff --git a/tests/Stampeded.Core.Tests/WorktreeCacheTests.cs b/tests/Stampeded.Core.Tests/WorktreeCacheTests.cs index 307f205..f16f4f8 100644 --- a/tests/Stampeded.Core.Tests/WorktreeCacheTests.cs +++ b/tests/Stampeded.Core.Tests/WorktreeCacheTests.cs @@ -34,13 +34,7 @@ public void RemoveTemporaryDirectories() Environment.SetEnvironmentVariable("XDG_CACHE_HOME", null); foreach (var dir in temporaryDirectories) { - try - { - Directory.Delete(dir, recursive: true); - } - catch (IOException) - { - } + TempDirectory.Delete(dir); } temporaryDirectories.Clear(); }