Skip to content

Commit 537fbb1

Browse files
Seggrclaude
andcommitted
v0.2.1: update flow, single-instance, decoupled plugin versions, bug fixes
Host changes: - Single-instance enforcement via per-user named mutex (Local\Proxylayer.AzureTray.SingleInstance). - Update-detection event on IUpdateService; new UpdateAvailableNotifier IHostedService surfaces a non-timing-out blue ActionRequest toast. - Blue clickable banner at the top of the Settings window when an update is pending. - Default Velopack feed URL set to the Proxylayer/AzureTray GitHub Releases pattern. Plugin contract surface (additive, no PluginApiVersion bump): - NotificationSeverity enum on NotificationRequest (Info / Update / Warning / Error). - ActionRequest(Title, Message, ActionLabel) and ActionResult(bool) for single-call-to-action notifications. Release architecture: - Plugin packages decoupled from the host: each csproj declares its own <Version>. - release.yml drops plugin pack/push entirely; new publish-plugins.yml is workflow_dispatch with a per-plugin selector. - AzureTray.Plugin.Contracts bumped to 0.2.1 (new types); PIM and LAPS stay at 0.2.0. Fixes: - LogViewerViewModel ctor ordering NRE that prevented the Log Viewer from opening. - PIM activation failures now show the user the underlying Graph/ARM error code + message via a red error toast instead of vanishing silently. Both PIM HTTP clients now preserve response body in their exceptions. - HostClipboard marshals to the WPF dispatcher's UI thread to avoid STA failures from background-thread callers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6924d70 commit 537fbb1

26 files changed

Lines changed: 797 additions & 94 deletions
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
name: Publish plugins
2+
3+
# Plugin packages are released independently from the host:
4+
# * The host's release workflow (release.yml) handles the Velopack
5+
# installer + draft GitHub Release; it does NOT touch nuget.org.
6+
# * This workflow handles plugin .nupkg publish. Run it manually
7+
# whenever a plugin's <Version> in its csproj changes.
8+
#
9+
# Each plugin tracks its own version in its csproj. nuget.org's
10+
# `--skip-duplicate` makes pushing an unchanged version a no-op, so
11+
# selecting "all" never republishes versions that already exist.
12+
13+
on:
14+
workflow_dispatch:
15+
inputs:
16+
plugin:
17+
description: Which package to publish
18+
type: choice
19+
required: true
20+
default: all
21+
options:
22+
- all
23+
- Contracts
24+
- PIM
25+
- LAPS
26+
27+
permissions:
28+
contents: read
29+
id-token: write # for attest-build-provenance
30+
attestations: write # for attest-build-provenance
31+
32+
concurrency:
33+
group: publish-plugins
34+
cancel-in-progress: false
35+
36+
jobs:
37+
publish:
38+
runs-on: windows-latest
39+
40+
steps:
41+
- name: Checkout
42+
uses: actions/checkout@v4
43+
with:
44+
fetch-depth: 0 # SourceLink needs commit history
45+
46+
- name: Setup .NET
47+
uses: actions/setup-dotnet@v4
48+
with:
49+
global-json-file: global.json
50+
51+
- name: Restore
52+
run: dotnet restore AzureTray.sln
53+
54+
- name: Build (Release)
55+
run: dotnet build AzureTray.sln --configuration Release --no-restore
56+
57+
# Pack only the selected projects. Each csproj declares its own
58+
# <Version> so no /p:Version override is passed — the package
59+
# version is whatever the csproj says.
60+
- name: Pack
61+
shell: pwsh
62+
run: |
63+
$plugin = '${{ github.event.inputs.plugin }}'
64+
$projects = @{
65+
'Contracts' = 'src/AzureTray.Plugin.Contracts/AzureTray.Plugin.Contracts.csproj'
66+
'PIM' = 'src/AzureTray.Plugin.PIM/AzureTray.Plugin.PIM.csproj'
67+
'LAPS' = 'src/AzureTray.Plugin.LAPS/AzureTray.Plugin.LAPS.csproj'
68+
}
69+
$selected = if ($plugin -eq 'all') { $projects.Values } else { @($projects[$plugin]) }
70+
71+
if (-not (Test-Path NuGet)) { New-Item -ItemType Directory -Path NuGet | Out-Null }
72+
foreach ($csproj in $selected) {
73+
Write-Host "Packing $csproj …" -ForegroundColor Cyan
74+
dotnet pack $csproj --configuration Release --no-build --output NuGet
75+
if ($LASTEXITCODE -ne 0) { throw "dotnet pack failed for $csproj" }
76+
}
77+
78+
- name: Upload nupkgs as artifact
79+
uses: actions/upload-artifact@v4
80+
with:
81+
name: plugin-packages
82+
path: NuGet/*.nupkg
83+
if-no-files-found: error
84+
85+
- name: Attest build provenance
86+
uses: actions/attest-build-provenance@v2
87+
with:
88+
subject-path: NuGet/*.nupkg
89+
90+
- name: Check NuGet config
91+
id: nugetcfg
92+
shell: pwsh
93+
env:
94+
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
95+
run: |
96+
$enabled = -not [string]::IsNullOrWhiteSpace($env:NUGET_API_KEY)
97+
"enabled=$($enabled.ToString().ToLower())" >> $env:GITHUB_OUTPUT
98+
Write-Host "Push to nuget.org enabled: $enabled"
99+
100+
- name: Push to nuget.org
101+
if: steps.nugetcfg.outputs.enabled == 'true'
102+
shell: pwsh
103+
env:
104+
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
105+
run: |
106+
# --skip-duplicate keeps re-runs idempotent: a plugin whose
107+
# csproj version hasn't been bumped since its last publish
108+
# is a no-op rather than a failure.
109+
$packages = Get-ChildItem NuGet\*.nupkg | Where-Object { $_.Name -notlike '*.symbols.nupkg' }
110+
foreach ($pkg in $packages) {
111+
Write-Host "Pushing $($pkg.Name)…"
112+
dotnet nuget push $pkg.FullName `
113+
--api-key $env:NUGET_API_KEY `
114+
--source https://api.nuget.org/v3/index.json `
115+
--skip-duplicate
116+
if ($LASTEXITCODE -ne 0) { throw "nuget push failed for $($pkg.Name)" }
117+
}

.github/workflows/release.yml

Lines changed: 5 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -65,56 +65,11 @@ jobs:
6565
--logger "trx;LogFileName=test-results.trx"
6666
--results-directory TestResults
6767
68-
# --- NuGet: pack + push plugin contracts and the in-repo plugins ----
69-
# Plugins are tagged "proxylayer.azuretray-plugin" (set in each
70-
# plugin's <PackageTags>) so the host's in-app plugin browser can
71-
# discover them via the nuget.org search API.
72-
# NUGET_API_KEY is the only secret required for publishing. If
73-
# absent (fork without the secret, draft of a tag, etc.) the
74-
# push step is skipped automatically — the package still gets
75-
# produced and uploaded as a build artifact.
76-
- name: Check NuGet config
77-
id: nugetcfg
78-
shell: pwsh
79-
env:
80-
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
81-
run: |
82-
$enabled = -not [string]::IsNullOrWhiteSpace($env:NUGET_API_KEY)
83-
"enabled=$($enabled.ToString().ToLower())" >> $env:GITHUB_OUTPUT
84-
Write-Host "Push to nuget.org enabled: $enabled"
85-
86-
- name: Pack NuGet packages
87-
run: >
88-
dotnet pack AzureTray.sln
89-
--configuration Release
90-
--no-build
91-
--output NuGet
92-
/p:Version=${{ steps.ver.outputs.version }}
93-
94-
- name: Upload NuGet packages as build artifact
95-
uses: actions/upload-artifact@v4
96-
with:
97-
name: nuget-packages
98-
path: NuGet/*.nupkg
99-
if-no-files-found: error
100-
101-
- name: Push to nuget.org
102-
if: steps.nugetcfg.outputs.enabled == 'true'
103-
shell: pwsh
104-
env:
105-
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
106-
run: |
107-
$packages = Get-ChildItem NuGet\*.nupkg | Where-Object { $_.Name -notlike '*.symbols.nupkg' }
108-
foreach ($pkg in $packages) {
109-
Write-Host "Pushing $($pkg.Name)…"
110-
# --skip-duplicate keeps re-runs idempotent when a previous
111-
# publish already landed (or when a tag is re-built).
112-
dotnet nuget push $pkg.FullName `
113-
--api-key $env:NUGET_API_KEY `
114-
--source https://api.nuget.org/v3/index.json `
115-
--skip-duplicate
116-
if ($LASTEXITCODE -ne 0) { throw "nuget push failed for $($pkg.Name)" }
117-
}
68+
# Plugin packages (Contracts, PIM, LAPS) are published independently
69+
# via the workflow_dispatch trigger on publish-plugins.yml — they
70+
# track their own version in each csproj and only ship when the
71+
# author explicitly publishes a new version, so host releases don't
72+
# churn nuget.org with no-op republishes.
11873

11974
# --- Velopack pack of the self-contained tray app ------------------
12075
- name: Publish self-contained win-x64
@@ -157,7 +112,6 @@ jobs:
157112
subject-path: |
158113
Releases/*.exe
159114
Releases/*.nupkg
160-
NuGet/*.nupkg
161115
162116
- name: Upload test results
163117
if: always()

CHANGELOG.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.2.1] — 2026-05-12
11+
12+
### Added
13+
14+
- **Single-instance enforcement.** The host now holds a per-user named mutex (`Local\Proxylayer.AzureTray.SingleInstance`); launching `AzureTray.exe` while it's already running for the same user logs and exits cleanly instead of starting a second tray icon. Crash-safe via `AbandonedMutexException` — a kill-3 leaves the next launch able to acquire.
15+
- **Update-available notification.** When `IUpdateService.CheckOnStartupAsync` detects a new release and finishes downloading it, the host surfaces a non-timing-out `ActionRequest` toast with a blue accent stripe and an "Update now" button. Dismissing keeps the option available via the Settings banner; clicking "Update now" applies via Velopack and restarts.
16+
- **Update banner in Settings.** A clickable blue banner above the cards shows when an update is pending; click to apply.
17+
- **Default Velopack feed URL** set to `https://github.com/Proxylayer/AzureTray/releases/latest/download` so installs pick up the latest published release automatically. Override to `""` to disable update checks.
18+
19+
### Changed
20+
21+
- **Plugin versions decoupled from the host.** `AzureTray.Plugin.Contracts`, `AzureTray.Plugin.PIM`, and `AzureTray.Plugin.LAPS` each declare their own `<Version>` in their csproj. Host tag-driven releases no longer republish plugin packages; a separate manual workflow ([`publish-plugins.yml`](.github/workflows/publish-plugins.yml)) handles plugin publishes. `--skip-duplicate` keeps re-runs idempotent.
22+
- `AzureTray.Plugin.Contracts` bumped to `0.2.1` (new types added — see below). `AzureTray.Plugin.PIM` and `AzureTray.Plugin.LAPS` remain at `0.2.0`.
23+
24+
### Plugin contract surface (additive, no PluginApiVersion bump)
25+
26+
- New `NotificationSeverity` enum (`Info` / `Update` / `Warning` / `Error`) on `NotificationRequest` base record. Drives the notification's accent stripe color.
27+
- New `ActionRequest(Title, Message, ActionLabel)` notification type for single-call-to-action prompts. Never auto-dismisses.
28+
- New `ActionResult(bool ActionInvoked)` distinguishes "user clicked the action" from `DismissedResult`.
29+
30+
### Fixed
31+
32+
- **Log Viewer wouldn't open**: `LogViewerViewModel` ctor assigned `SelectedClassOption` *before* `EntriesView` was initialised, so the partial `OnSelectedClassOptionChanged` threw NRE inside the ctor and the window never appeared. Reordered the ctor to initialise the CollectionViewSource first.
33+
- **PIM activation failures had no user feedback**: a failed `Activate role` click logged the error but said nothing to the user. The watcher now surfaces a red error toast with the underlying Graph/ARM error code + message, and an info toast on the happy path. Powered by a new `EnsureSuccessOrThrowWithBodyAsync` helper in both PIM HTTP clients that preserves the service's response body in the exception (the standard `EnsureSuccessStatusCode` discards it).
34+
- **Clipboard writes from background threads**: `HostClipboard.SetText` was failing with `ThreadStateException: Current thread must be set to single thread apartment (STA)` when invoked from a thread-pool thread (e.g. LAPS copy-password from a watcher continuation). Now marshals to the WPF dispatcher's UI thread before touching `System.Windows.Clipboard`.
35+
1036
## [0.2.0] — 2026-05-11
1137

1238
### Changed
@@ -107,6 +133,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
107133

108134
- This release is foundation only; no features from `Azure.PIM.Tray` have been ported yet.
109135

110-
[Unreleased]: https://github.com/Proxylayer/AzureTray/compare/v0.2.0...HEAD
136+
[Unreleased]: https://github.com/Proxylayer/AzureTray/compare/v0.2.1...HEAD
137+
[0.2.1]: https://github.com/Proxylayer/AzureTray/compare/v0.2.0...v0.2.1
111138
[0.2.0]: https://github.com/Proxylayer/AzureTray/compare/v0.1.0...v0.2.0
112139
[0.1.0]: https://github.com/Proxylayer/AzureTray/releases/tag/v0.1.0

CONTRIBUTING.md

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -70,15 +70,16 @@ The `.editorconfig` enforces most of this. Run `dotnet format --verify-no-change
7070

7171
These rules are configured in the GitHub UI; this document records the intent so reviewers know what to expect.
7272

73-
## Releasing
73+
## Releasing the host
7474

75-
Releases are tag-driven. To cut one:
75+
Host releases are tag-driven and **do not touch the plugin packages on nuget.org** — that's a separate workflow described below. To cut a host release:
7676

7777
1. Update `CHANGELOG.md`: move entries from `[Unreleased]` to a new `[X.Y.Z]` section with today's date.
78-
2. Commit on `main` and push.
79-
3. Tag the commit: `git tag -a vX.Y.Z -m "vX.Y.Z" && git push origin vX.Y.Z`.
78+
2. Bump `<Version>` in `Directory.Build.props` to the same `X.Y.Z`.
79+
3. Commit on `main` and push.
80+
4. Tag the commit: `git tag -a vX.Y.Z -m "vX.Y.Z" && git push origin vX.Y.Z`.
8081

81-
The `.github/workflows/release.yml` workflow runs on tag push. It:
82+
The [`release.yml`](.github/workflows/release.yml) workflow runs on tag push. It:
8283

8384
- Builds and runs the full test suite. A test failure blocks the release.
8485
- Publishes a self-contained `win-x64` build via `dotnet publish`.
@@ -91,9 +92,24 @@ Review the draft release on GitHub, then publish it manually. The "draft" gate i
9192

9293
Code signing is not enabled for v0.x. Release integrity is established via GitHub's build-provenance attestation (see [SECURITY.md](SECURITY.md)).
9394

94-
Releases are also triggerable manually from the GitHub Actions tab via `workflow_dispatch` (supply a version like `1.2.3`). The workflow creates the matching tag for you when the release is published.
95+
Host releases are also triggerable manually from the GitHub Actions tab via `workflow_dispatch` (supply a version like `1.2.3`). The workflow creates the matching tag for you when the release is published.
9596

96-
The release workflow also packs `AzureTray.Plugin.Contracts`, `AzureTray.Plugin.PIM`, and `AzureTray.Plugin.LAPS` as NuGet packages and (when the `NUGET_API_KEY` repo secret is present) pushes them to nuget.org. For test-publishing prereleases from a developer machine without going through CI, use [scripts/publish-plugins-prerelease.ps1](scripts/publish-plugins-prerelease.ps1).
97+
## Releasing the plugin packages
98+
99+
Plugins (`AzureTray.Plugin.Contracts`, `AzureTray.Plugin.PIM`, `AzureTray.Plugin.LAPS`) version themselves **independently** of the host — each csproj declares its own `<Version>`. This way:
100+
101+
- Host releases that touch only the EXE don't churn nuget.org with no-op plugin re-publishes.
102+
- A plugin author can ship a fix on a plugin without waiting for a host release.
103+
- Plugin consumers pinning to `AzureTray.Plugin.PIM 0.2.0` don't see the version creep when the host goes to 0.3.0.
104+
105+
To publish a plugin:
106+
107+
1. Bump `<Version>` in the relevant csproj (`src/AzureTray.Plugin.Contracts/AzureTray.Plugin.Contracts.csproj`, etc.).
108+
2. Commit and push to `main`.
109+
3. Open the GitHub Actions UI → **Publish plugins** workflow → **Run workflow** → choose which package (`Contracts`, `PIM`, `LAPS`, or `all`).
110+
4. The workflow ([`publish-plugins.yml`](.github/workflows/publish-plugins.yml)) packs the selected csproj(s), attests build provenance, and pushes to nuget.org (when `NUGET_API_KEY` is set). `--skip-duplicate` makes pushes idempotent — selecting "all" without bumping any csproj is safe and produces no new versions.
111+
112+
For test-publishing prereleases from a developer machine, see [scripts/publish-plugins-prerelease.ps1](scripts/publish-plugins-prerelease.ps1) — it stamps a `-preview.YYYYMMDDHHMM` suffix on top of each csproj's declared `<Version>`.
97113

98114
## Building a plugin for AzureTray
99115

Directory.Build.props

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
</PropertyGroup>
1616

1717
<PropertyGroup>
18-
<Version>0.2.0</Version>
18+
<!-- Host EXE version. Plugin packages each declare their own
19+
<Version> in their csproj so they're decoupled from this. -->
20+
<Version>0.2.1</Version>
1921
<Authors>ProxyLayer</Authors>
2022
<Product>AzureTray</Product>
2123
<Copyright>Copyright (c) ProxyLayer</Copyright>

src/AzureTray.Plugin.Contracts/AzureTray.Plugin.Contracts.csproj

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@
99
FileVersion follows the package version for diagnostic clarity. -->
1010
<AssemblyVersion>1.0.0.0</AssemblyVersion>
1111

12+
<!-- Plugin-side packages declare their own version, decoupled from
13+
the host's <Version> in Directory.Build.props. The host can ship
14+
many releases without changing this; we only bump when the
15+
contracts surface itself changes. Bump when adding members. -->
16+
<Version>0.2.1</Version>
17+
1218
<IsPackable>true</IsPackable>
1319
<PackageId>AzureTray.Plugin.Contracts</PackageId>
1420
<PackageDescription>Plugin contract assembly for AzureTray. Reference this package to build a tray plugin. The host's plugin loader resolves these types from its own copy at runtime, so plugin packages should mark this reference PrivateAssets="all".</PackageDescription>

src/AzureTray.Plugin.Contracts/NotificationRequest.cs

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,28 @@
33
namespace AzureTray.Plugin.Contracts;
44

55
// Inputs to INotifier.ShowAsync. Use the concrete records — the host renders
6-
// different controls based on the runtime type.
7-
public abstract record NotificationRequest(string Title, string Message);
6+
// different controls based on the runtime type. Severity is a host hint for
7+
// the accent stripe colour (info / update / warning / error); plugins may
8+
// omit it and inherit Info.
9+
public abstract record NotificationRequest(string Title, string Message)
10+
{
11+
public NotificationSeverity Severity { get; init; } = NotificationSeverity.Info;
12+
}
13+
14+
public enum NotificationSeverity
15+
{
16+
// Default. Neutral grey accent stripe.
17+
Info,
18+
19+
// Update-available / call-to-action. Blue accent stripe.
20+
Update,
21+
22+
// Caution-but-not-failed. Amber accent stripe.
23+
Warning,
24+
25+
// Failure / blocking issue. Red accent stripe.
26+
Error,
27+
}
828

929
// Two-button confirmation. Returns YesNoResult.
1030
public sealed record YesNoRequest(string Title, string Message)
@@ -32,3 +52,11 @@ public sealed record TextInputRequest(
3252
// auto-dismiss after a timeout. Always resolves to DismissedResult.
3353
public sealed record InformationRequest(string Title, string Message)
3454
: NotificationRequest(Title, Message);
55+
56+
// Single-call-to-action notification. Renders a primary button labelled
57+
// ActionLabel ("Update now", "Open file", "Retry") plus a close affordance.
58+
// Returns ActionResult(true) if the user invokes the action, or
59+
// DismissedResult if they close the notification. Never auto-dismisses —
60+
// intended for moments that need explicit user attention.
61+
public sealed record ActionRequest(string Title, string Message, string ActionLabel)
62+
: NotificationRequest(Title, Message);

src/AzureTray.Plugin.Contracts/NotificationResult.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,8 @@ public sealed record ChoiceResult(string? SelectedChoice, string? OtherText) : N
1717

1818
public sealed record TextInputResult(string Text) : NotificationResult;
1919

20+
// Result from an ActionRequest. ActionInvoked is true when the user clicked
21+
// the primary action button; false when they dismissed.
22+
public sealed record ActionResult(bool ActionInvoked) : NotificationResult;
23+
2024
public sealed record DismissedResult : NotificationResult;

src/AzureTray.Plugin.LAPS/AzureTray.Plugin.LAPS.csproj

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@
99
is informational; the host gates on the in-code ITrayPlugin.ApiVersion. -->
1010
<AssemblyVersion>1.0.0.0</AssemblyVersion>
1111

12+
<!-- Plugin version is tracked independently of the host. Bump when the
13+
plugin's behaviour changes; host releases don't affect this value.
14+
Publish via the workflow_dispatch trigger on publish-plugins.yml. -->
15+
<Version>0.2.0</Version>
16+
1217
<!-- See AzureTray.Plugin.PIM for rationale. Bundles transitive
1318
NuGet deps into the build output so they ship inside the
1419
.nupkg's lib/net8.0/ folder. -->

0 commit comments

Comments
 (0)