Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions .github/workflows/build-terminal.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,6 @@
<PackageVersion Include="System.CommandLine" Version="2.0.11" />
<PackageVersion Include="xunit.v3" Version="3.2.2" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
</ItemGroup>
</Project>
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
84 changes: 83 additions & 1 deletion docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
<PackageReference Include="Devolutions.Terminal.Control" Version="2026.3.0" />
```

`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
`<ProjectReference ... PrivateAssets="all" />` 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.
Expand Down
4 changes: 4 additions & 0 deletions samples/Devolutions.Terminal.Control.Sample/App.axaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Devolutions.Terminal.Control.Sample.App">
</Application>
20 changes: 20 additions & 0 deletions samples/Devolutions.Terminal.Control.Sample/App.axaml.cs
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<Project Sdk="Microsoft.NET.Sdk">

<!--
Minimal Avalonia app that consumes Devolutions.Terminal.Control purely as a
NuGet PackageReference (see NuGet.Config next to this file), never as a
ProjectReference. It exists to:
- document real-world usage of the published package, and
- act as a build-time smoke test for the `nuget-pack` CI job, which
packs Devolutions.Terminal.Control locally and then builds this
project against that freshly produced package.

Deliberately excluded from Devolutions.Terminal.slnx: it must resolve the
package from a NuGet feed, so it cannot be part of the normal
build/test/restore path that runs before the package has ever been
packed or published.
-->

<PropertyGroup>
<OutputType>WinExe</OutputType>
<RootNamespace>Devolutions.Terminal.Control.Sample</RootNamespace>
<AssemblyName>Devolutions.Terminal.Control.Sample</AssemblyName>
<Description>Minimal Avalonia app demonstrating Devolutions.Terminal.Control consumed as a NuGet package.</Description>
<IsPackable>false</IsPackable>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Avalonia.Desktop" Version="12.1.1" />
<!--
"*" always resolves to whatever the local "local-artifacts" feed
currently holds. NuGet.Config maps this package id exclusively to that
feed, so this can never silently pick up an already-published version
from nuget.org instead of the package under test.
-->
<PackageReference Include="Devolutions.Terminal.Control" Version="*" />
</ItemGroup>

</Project>
12 changes: 12 additions & 0 deletions samples/Devolutions.Terminal.Control.Sample/MainWindow.axaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="Devolutions.Terminal.Control.Sample.MainWindow"
Title="Devolutions.Terminal.Control sample"
Width="960"
Height="600">
<!--
TermControl has no parameterless constructor usable from XAML (its
constructor takes an optional ITerminalEngine), so it is instantiated
in code-behind instead - see MainWindow.axaml.cs.
-->
</Window>
26 changes: 26 additions & 0 deletions samples/Devolutions.Terminal.Control.Sample/MainWindow.axaml.cs
Original file line number Diff line number Diff line change
@@ -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);
};
}
}
17 changes: 17 additions & 0 deletions samples/Devolutions.Terminal.Control.Sample/NuGet.Config
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
<!-- Populated by `dotnet pack src/Devolutions.Terminal.Control -o artifacts/nuget` -->
<add key="local-artifacts" value="../../artifacts/nuget" />
</packageSources>
<packageSourceMapping>
<packageSource key="local-artifacts">
<package pattern="Devolutions.Terminal.Control" />
</packageSource>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
</packageSourceMapping>
</configuration>
15 changes: 15 additions & 0 deletions samples/Devolutions.Terminal.Control.Sample/Program.cs
Original file line number Diff line number Diff line change
@@ -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<App>()
.UsePlatformDetect()
.LogToTrace();
}
31 changes: 31 additions & 0 deletions samples/Devolutions.Terminal.Control.Sample/README.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions src/Devolutions.Terminal.App/Devolutions.Terminal.App.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
<ItemGroup>
<ProjectReference Include="..\Devolutions.Terminal.Control\Devolutions.Terminal.Control.csproj" />
<ProjectReference Include="..\Devolutions.Terminal.Connection\Devolutions.Terminal.Connection.csproj" />
<ProjectReference Include="..\Devolutions.Terminal.Core\Devolutions.Terminal.Core.csproj" />
<ProjectReference Include="..\Devolutions.Terminal.Render\Devolutions.Terminal.Render.csproj" />
<ProjectReference Include="..\Devolutions.Terminal.Ghostty\Devolutions.Terminal.Ghostty.csproj" />
<ProjectReference Include="..\Devolutions.Terminal.Settings\Devolutions.Terminal.Settings.csproj" />
<ProjectReference Include="..\Devolutions.Terminal.Package\Devolutions.Terminal.Package.csproj" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
<RootNamespace>Devolutions.Terminal.Connection</RootNamespace>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Description>ConPTY and Azure Cloud Shell connections for the .NET Windows Terminal port.</Description>
<!-- Not published standalone; bundled into the Devolutions.Terminal.Control NuGet package. -->
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Devolutions.Terminal.Core\Devolutions.Terminal.Core.csproj" />
Expand Down
Loading
Loading