From ee500890b5bbc29aede55c9e1c8be6c712455f11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Wed, 16 Sep 2026 16:10:32 -0400 Subject: [PATCH] Publish Devolutions.Terminal.Control as a reusable NuGet package - Bundle Core/Render/Connection/Settings into Control's package (IsPackable=false + PrivateAssets=all + CopyProjectReferencesToPackage) - Add package metadata, SourceLink, symbol package - Add samples/Devolutions.Terminal.Control.Sample as a PackageReference-based consumer smoke test, wired into the nuget-pack CI job - Add nuget-pack/nuget-publish CI jobs (nuget.org, tag-gated) - Document packaging/consumption in docs/release.md and README.md --- .github/workflows/build-terminal.yml | 84 +++++++++++++++++++ Directory.Packages.props | 1 + README.md | 7 ++ docs/release.md | 84 ++++++++++++++++++- .../App.axaml | 4 + .../App.axaml.cs | 20 +++++ ...Devolutions.Terminal.Control.Sample.csproj | 39 +++++++++ .../MainWindow.axaml | 12 +++ .../MainWindow.axaml.cs | 26 ++++++ .../NuGet.Config | 17 ++++ .../Program.cs | 15 ++++ .../README.md | 31 +++++++ .../Devolutions.Terminal.App.csproj | 2 + .../Devolutions.Terminal.Connection.csproj | 2 + .../Devolutions.Terminal.Control.csproj | 66 ++++++++++++++- src/Devolutions.Terminal.Control/PACKAGE.md | 34 ++++++++ .../Devolutions.Terminal.Core.csproj | 2 + .../Devolutions.Terminal.Render.csproj | 2 + .../Devolutions.Terminal.Settings.csproj | 2 + .../Devolutions.Terminal.Control.Tests.csproj | 4 + .../Devolutions.Terminal.Bench.csproj | 4 + 21 files changed, 453 insertions(+), 5 deletions(-) create mode 100644 samples/Devolutions.Terminal.Control.Sample/App.axaml create mode 100644 samples/Devolutions.Terminal.Control.Sample/App.axaml.cs create mode 100644 samples/Devolutions.Terminal.Control.Sample/Devolutions.Terminal.Control.Sample.csproj create mode 100644 samples/Devolutions.Terminal.Control.Sample/MainWindow.axaml create mode 100644 samples/Devolutions.Terminal.Control.Sample/MainWindow.axaml.cs create mode 100644 samples/Devolutions.Terminal.Control.Sample/NuGet.Config create mode 100644 samples/Devolutions.Terminal.Control.Sample/Program.cs create mode 100644 samples/Devolutions.Terminal.Control.Sample/README.md create mode 100644 src/Devolutions.Terminal.Control/PACKAGE.md diff --git a/.github/workflows/build-terminal.yml b/.github/workflows/build-terminal.yml index d231561..ad7d31a 100644 --- a/.github/workflows/build-terminal.yml +++ b/.github/workflows/build-terminal.yml @@ -159,6 +159,90 @@ jobs: - name: Test run: dotnet test Devolutions.Terminal.slnx -c Release --no-build -p:VersionPrefix=${{ needs.release-metadata.outputs.release_version }} + nuget-pack: + name: Pack Devolutions.Terminal.Control NuGet package + runs-on: windows-latest + needs: + - release-metadata + - build + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Cache native libraries + uses: actions/cache@v4 + with: + path: | + native/noto-emoji/NotoColorEmoji.ttf + key: native-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/**/*.ps1', 'native/ghostty/ghostty-upstream.json', 'native/linux-pty/dt-pty-host.c', 'native/noto-emoji/noto-emoji.json') }} + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Pack Devolutions.Terminal.Control + run: >- + dotnet pack src/Devolutions.Terminal.Control/Devolutions.Terminal.Control.csproj + -c Release + -o artifacts/nuget + -p:VersionPrefix=${{ needs.release-metadata.outputs.release_version }} + + - name: Smoke-test package via samples/Devolutions.Terminal.Control.Sample + run: dotnet build samples/Devolutions.Terminal.Control.Sample -c Release + + - name: Upload NuGet package artifacts + uses: actions/upload-artifact@v4 + with: + name: DevolutionsTerminal-nuget-packages + path: artifacts/nuget + if-no-files-found: error + + nuget-publish: + name: Publish Devolutions.Terminal.Control to nuget.org + if: ${{ needs.release-metadata.outputs.dry_run != 'true' && startsWith(github.ref, 'refs/tags/') }} + needs: + - release-metadata + - nuget-pack + runs-on: ubuntu-latest + environment: + name: nuget.org + steps: + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Download NuGet package artifacts + uses: actions/download-artifact@v4 + with: + name: DevolutionsTerminal-nuget-packages + path: artifacts/nuget + + - name: Push to nuget.org + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + shell: pwsh + run: | + if ([string]::IsNullOrWhiteSpace($env:NUGET_API_KEY)) { + throw "Missing NUGET_API_KEY secret." + } + + $packages = Get-ChildItem -LiteralPath artifacts/nuget -Filter "*.nupkg" -File + if ($packages.Count -eq 0) { + throw "No .nupkg files found to publish." + } + + foreach ($package in $packages) { + dotnet nuget push $package.FullName ` + --source https://api.nuget.org/v3/index.json ` + --api-key $env:NUGET_API_KEY ` + --skip-duplicate + if ($LASTEXITCODE -ne 0) { + throw "Failed to push $($package.Name) to nuget.org." + } + } + native-aot: name: NativeAOT ${{ matrix.rid }} runs-on: windows-latest diff --git a/Directory.Packages.props b/Directory.Packages.props index 69fc3f0..e40a7d1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,5 +18,6 @@ + diff --git a/README.md b/README.md index 9932c7e..a16027d 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,13 @@ Advanced VT protocols are documented in Azure Cloud Shell is documented in [docs/azure-cloud-shell.md](docs/azure-cloud-shell.md). Build and release gates are documented in [docs/release.md](docs/release.md). +`Devolutions.Terminal.Control` is also published to +[nuget.org](https://www.nuget.org/packages/Devolutions.Terminal.Control) as a +reusable, self-contained Avalonia terminal control package (it bundles +`Core`/`Render`/`Connection`/`Settings` internally) for embedding in other +Avalonia applications. See the "NuGet package" section of +[docs/release.md](docs/release.md) for packaging and consumption details. + ## Compatibility inventory ### Safety and compatibility settings diff --git a/docs/release.md b/docs/release.md index d016e93..9ab23ac 100644 --- a/docs/release.md +++ b/docs/release.md @@ -21,7 +21,8 @@ CI workflows: - `build-ghostty.yml` — compile `libghostty-vt` for every RID and upload artifacts (optional cache; not required to develop). - `build-terminal.yml` — restore natives from source, test, NativeAOT, Linux - packages, macOS `.app`/zip, MSIX. + packages, macOS `.app`/zip, MSIX, and the `Devolutions.Terminal.Control` + NuGet package. ## Developer build @@ -275,6 +276,87 @@ trusted publishing. Configure the `publish-test` and `publish-prod` environments as trusted publishers for the package on NuGet.org. Dry runs do not request a NuGet API key or publish the package. +## NuGet package (`Devolutions.Terminal.Control`) + +`src/Devolutions.Terminal.Control` is published to nuget.org as a single, +self-contained package so it can be embedded in other Avalonia applications +(for example, replacing an internal terminal-control package in a downstream +product). The package bundles the build output of `Devolutions.Terminal.Core`, +`Devolutions.Terminal.Render`, `Devolutions.Terminal.Connection`, and +`Devolutions.Terminal.Settings` directly into its `lib/net10.0` folder — those +four projects are marked `IsPackable=false` and are never published as +separate packages, so the only NuGet dependencies a consumer sees are the +genuine third-party ones (`Avalonia`, `Avalonia.Skia`, `SkiaSharp`, +`SkiaSharp.HarfBuzz`, and their Linux native-asset packages). + +Pack it locally: + +```powershell +dotnet pack src/Devolutions.Terminal.Control/Devolutions.Terminal.Control.csproj ` + -c Release -o artifacts/nuget +``` + +Consume it as a package (see `samples/Devolutions.Terminal.Control.Sample` +for a full working app): + +```xml + +``` + +`TermControl` has no parameterless constructor usable from XAML (its +constructor takes an optional `ITerminalEngine`), so instantiate it in +code-behind rather than declaring it directly as a XAML element: + +```csharp +using Devolutions.Terminal; +using Devolutions.Terminal.Settings; + +var terminal = new TermControl(); +Content = terminal; +await terminal.StartAsync(new ProfileSettings(), columns: 120, rows: 30); +``` + +`samples/Devolutions.Terminal.Control.Sample` is a minimal Avalonia app that +consumes the package this way via a plain `PackageReference` (never a +`ProjectReference`), and is deliberately excluded from +`Devolutions.Terminal.slnx` since it must resolve the package from a feed. Its +`NuGet.Config` maps the `Devolutions.Terminal.Control` package id exclusively +to a local `artifacts/nuget` feed via package source mapping, so it always +builds against whatever was just packed rather than an already-published +version. CI builds it in the `nuget-pack` job right after packing, as a +consumer smoke test that would catch packaging regressions (missing bundled +assemblies/assets, broken dependencies, API usage that doesn't actually work +from outside the repo) that an in-repo `ProjectReference` build cannot surface. + +CI packs the project in the `nuget-pack` job of `build-terminal.yml` on every +build, builds the sample against the freshly packed package as a smoke test, +and uploads the `.nupkg`/`.snupkg` as a workflow artifact. The +`nuget-publish` job pushes those packages to nuget.org only for tag-triggered, +non-dry-run runs, using the `NUGET_API_KEY` secret configured on the +`nuget.org` GitHub Environment. + +Required environment secret: + +- `NUGET_API_KEY` — an API key scoped to the `Devolutions.Terminal.Control` + package ID on nuget.org. + +When changing the internal project boundary (adding a new internal project +that `Control` needs, or a project that needs direct access to +`Core`/`Render`/`Connection`/`Settings` types), keep two things in sync: + +- Any new internal, non-packable dependency needs its own + `` entry in `Control.csproj` so + it is bundled into `lib/net10.0` without leaking a dangling NuGet + dependency. +- Any third-party `PackageReference` declared only on one of those internal + projects (such as `Render`'s SkiaSharp/HarfBuzzSharp references) must also + be declared directly on `Control.csproj`, because `PrivateAssets="all"` + prevents it from flowing into `Control`'s nuspec automatically. +- In-repo consumers (tests, tools, `Devolutions.Terminal.App`) that use + `Core`/`Render`/`Connection`/`Settings` types directly can no longer rely on + transitive project-reference flow through `Control` and must add their own + explicit `ProjectReference`. + ## Release gates 1. Regenerate `compat/windows-terminal.json` and review inventory changes. diff --git a/samples/Devolutions.Terminal.Control.Sample/App.axaml b/samples/Devolutions.Terminal.Control.Sample/App.axaml new file mode 100644 index 0000000..c76b918 --- /dev/null +++ b/samples/Devolutions.Terminal.Control.Sample/App.axaml @@ -0,0 +1,4 @@ + + diff --git a/samples/Devolutions.Terminal.Control.Sample/App.axaml.cs b/samples/Devolutions.Terminal.Control.Sample/App.axaml.cs new file mode 100644 index 0000000..42198ce --- /dev/null +++ b/samples/Devolutions.Terminal.Control.Sample/App.axaml.cs @@ -0,0 +1,20 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; + +namespace Devolutions.Terminal.Control.Sample; + +public sealed class App : Application +{ + public override void Initialize() => AvaloniaXamlLoader.Load(this); + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = new MainWindow(); + } + + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/samples/Devolutions.Terminal.Control.Sample/Devolutions.Terminal.Control.Sample.csproj b/samples/Devolutions.Terminal.Control.Sample/Devolutions.Terminal.Control.Sample.csproj new file mode 100644 index 0000000..6de3e41 --- /dev/null +++ b/samples/Devolutions.Terminal.Control.Sample/Devolutions.Terminal.Control.Sample.csproj @@ -0,0 +1,39 @@ + + + + + + WinExe + Devolutions.Terminal.Control.Sample + Devolutions.Terminal.Control.Sample + Minimal Avalonia app demonstrating Devolutions.Terminal.Control consumed as a NuGet package. + false + false + false + + + + + + + + + diff --git a/samples/Devolutions.Terminal.Control.Sample/MainWindow.axaml b/samples/Devolutions.Terminal.Control.Sample/MainWindow.axaml new file mode 100644 index 0000000..4e73ca4 --- /dev/null +++ b/samples/Devolutions.Terminal.Control.Sample/MainWindow.axaml @@ -0,0 +1,12 @@ + + + diff --git a/samples/Devolutions.Terminal.Control.Sample/MainWindow.axaml.cs b/samples/Devolutions.Terminal.Control.Sample/MainWindow.axaml.cs new file mode 100644 index 0000000..d18d623 --- /dev/null +++ b/samples/Devolutions.Terminal.Control.Sample/MainWindow.axaml.cs @@ -0,0 +1,26 @@ +using Avalonia.Controls; +using Devolutions.Terminal.Settings; + +namespace Devolutions.Terminal.Control.Sample; + +public sealed partial class MainWindow : Window +{ + public MainWindow() + { + InitializeComponent(); + + // TermControl has no parameterless constructor usable from XAML (its + // constructor takes an optional ITerminalEngine), so it is built here + // instead of declared in MainWindow.axaml. + var terminal = new Devolutions.Terminal.TermControl(); + Content = terminal; + + Opened += async (_, _) => + { + // A bare ProfileSettings() launches the platform default shell + // (Windows PowerShell on Windows, /bin/sh-family elsewhere via the + // package's own PTY connection). + await terminal.StartAsync(new ProfileSettings(), columns: 120, rows: 30); + }; + } +} diff --git a/samples/Devolutions.Terminal.Control.Sample/NuGet.Config b/samples/Devolutions.Terminal.Control.Sample/NuGet.Config new file mode 100644 index 0000000..bbaea6e --- /dev/null +++ b/samples/Devolutions.Terminal.Control.Sample/NuGet.Config @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/samples/Devolutions.Terminal.Control.Sample/Program.cs b/samples/Devolutions.Terminal.Control.Sample/Program.cs new file mode 100644 index 0000000..578c227 --- /dev/null +++ b/samples/Devolutions.Terminal.Control.Sample/Program.cs @@ -0,0 +1,15 @@ +using Avalonia; + +namespace Devolutions.Terminal.Control.Sample; + +internal static class Program +{ + [STAThread] + public static int Main(string[] args) => + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + + public static AppBuilder BuildAvaloniaApp() => + AppBuilder.Configure() + .UsePlatformDetect() + .LogToTrace(); +} diff --git a/samples/Devolutions.Terminal.Control.Sample/README.md b/samples/Devolutions.Terminal.Control.Sample/README.md new file mode 100644 index 0000000..72aa0ad --- /dev/null +++ b/samples/Devolutions.Terminal.Control.Sample/README.md @@ -0,0 +1,31 @@ +# Devolutions.Terminal.Control sample + +A minimal Avalonia desktop app that consumes the published +[`Devolutions.Terminal.Control`](../../src/Devolutions.Terminal.Control) NuGet +package via a plain `PackageReference` — never a `ProjectReference`. It exists +for two reasons: + +1. **Living usage example** for anyone integrating the control into their own + Avalonia app. +2. **Consumer smoke test** for CI: the `nuget-pack` workflow job packs + `Devolutions.Terminal.Control` locally, then builds this project against + that freshly produced package to catch packaging regressions (missing + bundled assemblies/assets, broken dependencies, wrong TFM, etc.) that a + `ProjectReference`-based build would never surface. + +This project is intentionally **not** part of `Devolutions.Terminal.slnx` — +it must resolve `Devolutions.Terminal.Control` from a NuGet feed, so it can't +be part of the normal restore/build/test path that runs before the package +has ever been packed. + +## Running it locally + +```pwsh +# From the repository root: +dotnet pack src/Devolutions.Terminal.Control/Devolutions.Terminal.Control.csproj -c Release -o artifacts/nuget +dotnet run --project samples/Devolutions.Terminal.Control.Sample +``` + +`NuGet.Config` in this folder maps the `Devolutions.Terminal.Control` package +id exclusively to `../../artifacts/nuget`, so it always picks up the package +you just packed rather than whatever version (if any) is on nuget.org. diff --git a/src/Devolutions.Terminal.App/Devolutions.Terminal.App.csproj b/src/Devolutions.Terminal.App/Devolutions.Terminal.App.csproj index c2e3b3a..b492f09 100644 --- a/src/Devolutions.Terminal.App/Devolutions.Terminal.App.csproj +++ b/src/Devolutions.Terminal.App/Devolutions.Terminal.App.csproj @@ -22,6 +22,8 @@ + + diff --git a/src/Devolutions.Terminal.Connection/Devolutions.Terminal.Connection.csproj b/src/Devolutions.Terminal.Connection/Devolutions.Terminal.Connection.csproj index a58b2f5..a141dcc 100644 --- a/src/Devolutions.Terminal.Connection/Devolutions.Terminal.Connection.csproj +++ b/src/Devolutions.Terminal.Connection/Devolutions.Terminal.Connection.csproj @@ -3,6 +3,8 @@ Devolutions.Terminal.Connection true ConPTY and Azure Cloud Shell connections for the .NET Windows Terminal port. + + false diff --git a/src/Devolutions.Terminal.Control/Devolutions.Terminal.Control.csproj b/src/Devolutions.Terminal.Control/Devolutions.Terminal.Control.csproj index 49f3fe6..3e58d76 100644 --- a/src/Devolutions.Terminal.Control/Devolutions.Terminal.Control.csproj +++ b/src/Devolutions.Terminal.Control/Devolutions.Terminal.Control.csproj @@ -2,16 +2,56 @@ Devolutions.Terminal Avalonia terminal control that renders Devolutions.Terminal.Core. + + Devolutions.Terminal.Control + avalonia;terminal;console;vt100;ansi;pty;conpty;xterm + PACKAGE.md + MIT + https://github.com/Devolutions/devolutions-terminal + true + true + true + snupkg + + + + + + + + + + + + + + - - - - + + + + + + + + + $(TargetsForTfmSpecificBuildOutput);CopyProjectReferencesToPackage + + + + + + diff --git a/src/Devolutions.Terminal.Control/PACKAGE.md b/src/Devolutions.Terminal.Control/PACKAGE.md new file mode 100644 index 0000000..fea5732 --- /dev/null +++ b/src/Devolutions.Terminal.Control/PACKAGE.md @@ -0,0 +1,34 @@ +# Devolutions.Terminal.Control + +A self-contained, NativeAOT-friendly Avalonia terminal control (`TermControl`) +extracted from [Devolutions Terminal](https://github.com/Devolutions/devolutions-terminal). + +This package bundles the VT engine, renderer, connection layer (ConPTY on +Windows, PTY on Linux/macOS), and Windows Terminal-compatible settings parser +that `TermControl` depends on, so a single package reference is enough to +embed a fully functional terminal in any Avalonia application. + +## Getting started + +```xml + + + +``` + +`TermControl` has no parameterless constructor usable from XAML (its +constructor takes an optional `ITerminalEngine`), so create and start it in +code-behind instead of declaring it as a XAML element: + +```csharp +using Devolutions.Terminal; +using Devolutions.Terminal.Settings; + +var terminal = new TermControl(); +Content = terminal; +await terminal.StartAsync(new ProfileSettings(), columns: 120, rows: 30); +``` + +See the [`samples/Devolutions.Terminal.Control.Sample`](https://github.com/Devolutions/devolutions-terminal/tree/main/samples/Devolutions.Terminal.Control.Sample) +project in the [Devolutions Terminal repository](https://github.com/Devolutions/devolutions-terminal) +for a full working app, plus the full source and documentation. diff --git a/src/Devolutions.Terminal.Core/Devolutions.Terminal.Core.csproj b/src/Devolutions.Terminal.Core/Devolutions.Terminal.Core.csproj index ef0f9a2..84d0557 100644 --- a/src/Devolutions.Terminal.Core/Devolutions.Terminal.Core.csproj +++ b/src/Devolutions.Terminal.Core/Devolutions.Terminal.Core.csproj @@ -2,5 +2,7 @@ Devolutions.Terminal.Core VT parser, text buffer, and terminal engine for the .NET Windows Terminal port. + + false diff --git a/src/Devolutions.Terminal.Render/Devolutions.Terminal.Render.csproj b/src/Devolutions.Terminal.Render/Devolutions.Terminal.Render.csproj index 4e8fae0..1a2988b 100644 --- a/src/Devolutions.Terminal.Render/Devolutions.Terminal.Render.csproj +++ b/src/Devolutions.Terminal.Render/Devolutions.Terminal.Render.csproj @@ -2,6 +2,8 @@ Devolutions.Terminal.Render Renderer-neutral snapshots and Skia rendering contracts. + + false diff --git a/src/Devolutions.Terminal.Settings/Devolutions.Terminal.Settings.csproj b/src/Devolutions.Terminal.Settings/Devolutions.Terminal.Settings.csproj index c199d96..61970d4 100644 --- a/src/Devolutions.Terminal.Settings/Devolutions.Terminal.Settings.csproj +++ b/src/Devolutions.Terminal.Settings/Devolutions.Terminal.Settings.csproj @@ -2,6 +2,8 @@ Devolutions.Terminal.Settings JSON settings model compatible with a Windows Terminal subset. + + false diff --git a/tests/Devolutions.Terminal.Control.Tests/Devolutions.Terminal.Control.Tests.csproj b/tests/Devolutions.Terminal.Control.Tests/Devolutions.Terminal.Control.Tests.csproj index de9216f..37c4d15 100644 --- a/tests/Devolutions.Terminal.Control.Tests/Devolutions.Terminal.Control.Tests.csproj +++ b/tests/Devolutions.Terminal.Control.Tests/Devolutions.Terminal.Control.Tests.csproj @@ -1,6 +1,10 @@ + + + + diff --git a/tools/Devolutions.Terminal.Bench/Devolutions.Terminal.Bench.csproj b/tools/Devolutions.Terminal.Bench/Devolutions.Terminal.Bench.csproj index 232b266..59d258d 100644 --- a/tools/Devolutions.Terminal.Bench/Devolutions.Terminal.Bench.csproj +++ b/tools/Devolutions.Terminal.Bench/Devolutions.Terminal.Bench.csproj @@ -12,5 +12,9 @@ + + + +