diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3e711715..c671be19 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Added
+- Server-driven notice system: security advisories and critical upgrade prompts are displayed at startup when a maintainer updates `notices.json`. Notices are suppressed once the user upgrades past the specified `minimumVersion`. Results are cached locally for 4 hours to avoid network calls on every invocation.
- `a365 cleanup azure --dry-run` — preview resources that would be deleted without making any changes or requiring Azure authentication
- `AppServiceAuthRequirementCheck` — validates App Service deployment token before `a365 deploy` begins, catching revoked grants (AADSTS50173) early
### Changed
diff --git a/notices.json b/notices.json
new file mode 100644
index 00000000..000eea5c
--- /dev/null
+++ b/notices.json
@@ -0,0 +1,5 @@
+{
+ "message": "",
+ "minimumVersion": null,
+ "expiresAt": "2000-01-01T00:00:00Z"
+}
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/NoticeModels.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/NoticeModels.cs
new file mode 100644
index 00000000..ee3c554c
--- /dev/null
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/NoticeModels.cs
@@ -0,0 +1,23 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace Microsoft.Agents.A365.DevTools.Cli.Models;
+
+///
+/// Notice fetched from the server-side notices endpoint.
+/// All fields are nullable — an empty or partial response means no active notice.
+///
+public record Notice(
+ string? Message,
+ string? MinimumVersion,
+ DateTimeOffset? ExpiresAt);
+
+///
+/// Result of a notice check — what the caller acts on.
+///
+public record NoticeResult(bool HasNotice, string? Message, string? UpdateCommand);
+
+///
+/// On-disk cache envelope for the notice, keyed by fetch timestamp.
+///
+public record NoticeCache(DateTimeOffset CachedAt, Notice? ActiveNotice);
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/VersionCheckModels.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/VersionCheckModels.cs
index 86cb6676..e5be9467 100644
--- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/VersionCheckModels.cs
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/VersionCheckModels.cs
@@ -16,3 +16,8 @@ public record VersionCheckResult(
string? CurrentVersion,
string? LatestVersion,
string? UpdateCommand);
+
+///
+/// On-disk cache envelope for the version check result, keyed by fetch timestamp.
+///
+public record VersionCheckCache(DateTimeOffset CachedAt, string? LatestVersion);
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs
index 3bfe0e49..cf52a5c4 100644
--- a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs
@@ -48,13 +48,51 @@ static async Task Main(string[] args)
ConfigureServices(services, logLevel, logFilePath);
var serviceProvider = services.BuildServiceProvider();
- // Check for updates (non-blocking, with timeout)
+ // Notice and version checks run concurrently — worst-case startup delay is ~2s, not ~4s.
+ using var noticeCts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
+ using var versionCts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
+
+ var noticeService = serviceProvider.GetRequiredService();
+ var versionCheckService = serviceProvider.GetRequiredService();
+
+ var noticeTask = noticeService.CheckForNoticeAsync(noticeCts.Token);
+ var versionTask = versionCheckService.CheckForUpdatesAsync(versionCts.Token);
+
+ await Task.WhenAll(
+ noticeTask.ContinueWith(_ => { }, TaskContinuationOptions.None),
+ versionTask.ContinueWith(_ => { }, TaskContinuationOptions.None));
+
+ // Display notice result
try
{
- var versionCheckService = serviceProvider.GetRequiredService();
- using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
- var result = await versionCheckService.CheckForUpdatesAsync(cts.Token);
+ var noticeResult = await noticeTask;
+ if (noticeResult.HasNotice)
+ {
+ const string separator = "------------------------------------------------------------";
+ startupLogger.LogWarning("");
+ startupLogger.LogWarning(separator);
+ startupLogger.LogWarning("URGENT NOTICE");
+ startupLogger.LogWarning(separator);
+ startupLogger.LogWarning("{Message}", noticeResult.Message);
+ startupLogger.LogWarning("");
+ startupLogger.LogWarning("To update, run: {Command}", noticeResult.UpdateCommand);
+ startupLogger.LogWarning(separator);
+ startupLogger.LogWarning("");
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ startupLogger.LogDebug("Notice check timed out");
+ }
+ catch (Exception ex)
+ {
+ startupLogger.LogDebug(ex, "Notice check failed: {Message}", ex.Message);
+ }
+ // Display version check result
+ try
+ {
+ var result = await versionTask;
if (result.UpdateAvailable)
{
startupLogger.LogWarning("");
@@ -194,6 +232,7 @@ private static void ConfigureServices(IServiceCollection services, LogLevel mini
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
// Add Microsoft Agent 365 Tooling Service with environment detection
services.AddSingleton(provider =>
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/INoticeService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/INoticeService.cs
new file mode 100644
index 00000000..e7a92fba
--- /dev/null
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/INoticeService.cs
@@ -0,0 +1,20 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Microsoft.Agents.A365.DevTools.Cli.Models;
+
+namespace Microsoft.Agents.A365.DevTools.Cli.Services;
+
+///
+/// Service for checking whether an active notice should be shown to the user.
+///
+public interface INoticeService
+{
+ ///
+ /// Checks for an active notice from the server-side notices endpoint.
+ /// Results are cached locally with a TTL to avoid a network call on every invocation.
+ ///
+ /// Cancellation token to abort the check.
+ /// Result indicating whether there is an active notice to display.
+ Task CheckForNoticeAsync(CancellationToken cancellationToken = default);
+}
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/IVersionCheckService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IVersionCheckService.cs
index 9316b058..4bb16dc8 100644
--- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/IVersionCheckService.cs
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IVersionCheckService.cs
@@ -6,7 +6,7 @@
namespace Microsoft.Agents.A365.DevTools.Cli.Services;
///
-/// Service for checking if newer versions of the CLI are available.
+/// Service for checking if a newer version of the CLI is available on NuGet.
///
public interface IVersionCheckService
{
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/VersionCheckHelper.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/VersionCheckHelper.cs
new file mode 100644
index 00000000..caaddd89
--- /dev/null
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/VersionCheckHelper.cs
@@ -0,0 +1,118 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace Microsoft.Agents.A365.DevTools.Cli.Services.Internal;
+
+///
+/// Shared helpers used by both and
+/// . Internal to the assembly.
+///
+internal static class VersionCheckHelper
+{
+ private const string PackageId = "Microsoft.Agents.A365.DevTools.Cli";
+
+ ///
+ /// Returns true if the current process is running inside a known CI/CD environment.
+ /// Both version and notice checks are skipped in CI to avoid unnecessary network calls.
+ ///
+ internal static bool IsRunningInCiCd()
+ {
+ var ciEnvVars = new[]
+ {
+ "CI", // Generic CI indicator
+ "TF_BUILD", // Azure DevOps
+ "GITHUB_ACTIONS", // GitHub Actions
+ "JENKINS_HOME", // Jenkins
+ "GITLAB_CI", // GitLab CI
+ "CIRCLECI", // CircleCI
+ "TRAVIS", // Travis CI
+ "TEAMCITY_VERSION", // TeamCity
+ "BUILDKITE", // Buildkite
+ "CODEBUILD_BUILD_ID" // AWS CodeBuild
+ };
+
+ return ciEnvVars.Any(envVar => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(envVar)));
+ }
+
+ ///
+ /// Parses a semantic version string into a comparable .
+ /// Handles formats such as "1.1.52", "1.1.0-preview.50", and "1.1.52-preview".
+ /// Throws if parsing fails.
+ ///
+ internal static Version ParseVersion(string versionString)
+ {
+ var parsed = TryParseVersion(versionString);
+ if (parsed == null)
+ throw new FormatException($"Invalid version format: {versionString}");
+ return parsed;
+ }
+
+ ///
+ /// Tries to parse a semantic version string. Returns null on failure.
+ ///
+ /// Supported formats:
+ /// - "1.1.52-preview" (iteration in base version number)
+ /// - "1.1.0-preview.50" (preview number is a separate segment)
+ ///
+ internal static Version? TryParseVersion(string versionString)
+ {
+ try
+ {
+ // Remove build metadata (+...)
+ var cleanVersion = versionString.Split('+')[0];
+
+ if (cleanVersion.Contains('-'))
+ {
+ var parts = cleanVersion.Split('-');
+ var baseVersion = parts[0]; // e.g., "1.1.52" or "1.1.0"
+
+ if (parts.Length > 1)
+ {
+ var previewPart = parts[1]; // e.g., "preview" or "preview.50"
+
+ // Format: "1.1.0-preview.50" — append preview number as revision
+ if (previewPart.StartsWith("preview.") && previewPart.Length > 8)
+ {
+ var previewNumber = previewPart.Substring(8);
+ cleanVersion = int.TryParse(previewNumber, out var preview)
+ ? $"{baseVersion}.{preview}"
+ : baseVersion;
+ }
+ else
+ {
+ // Format: "1.1.52-preview" — iteration is already in the base number
+ cleanVersion = baseVersion;
+ }
+ }
+ else
+ {
+ cleanVersion = baseVersion;
+ }
+ }
+
+ // Ensure at least 3 components for the Version constructor
+ var versionParts = cleanVersion.Split('.');
+ var componentsNeeded = 3 - versionParts.Length;
+ for (var i = 0; i < componentsNeeded; i++)
+ cleanVersion += ".0";
+
+ return new Version(cleanVersion);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Returns the appropriate dotnet tool update command for the given version string.
+ /// Appends --prerelease when the version is a preview build.
+ ///
+ internal static string GetUpdateCommand(string version)
+ {
+ var baseCommand = $"dotnet tool update -g {PackageId}";
+ return version.Contains("preview", StringComparison.OrdinalIgnoreCase)
+ ? $"{baseCommand} --prerelease"
+ : baseCommand;
+ }
+}
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/NoticeService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/NoticeService.cs
new file mode 100644
index 00000000..2b59dcd3
--- /dev/null
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/NoticeService.cs
@@ -0,0 +1,176 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Text.Json;
+using Microsoft.Agents.A365.DevTools.Cli.Models;
+using Microsoft.Agents.A365.DevTools.Cli.Services.Internal;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.Agents.A365.DevTools.Cli.Services;
+
+///
+/// Checks for an active notice posted to the server-side notices endpoint.
+/// Results are cached locally for hours to avoid a network
+/// call on every CLI invocation.
+///
+public class NoticeService : INoticeService
+{
+ private const string NoticesUrl = "https://raw.githubusercontent.com/microsoft/Agent365-devTools/main/notices.json";
+ private const string CacheFileName = "notice.cache.json";
+ private const int CacheTtlHours = 4;
+
+ private readonly ILogger _logger;
+ private readonly string _currentVersion;
+
+ public NoticeService(ILogger logger)
+ {
+ _logger = logger;
+ _currentVersion = Program.GetDisplayVersion();
+ }
+
+ ///
+ public async Task CheckForNoticeAsync(CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (VersionCheckHelper.IsRunningInCiCd())
+ {
+ _logger.LogDebug("Skipping notice check in CI/CD environment");
+ return new NoticeResult(false, null, null);
+ }
+
+ var notice = await GetNoticeWithCacheAsync(cancellationToken);
+
+ if (notice == null || string.IsNullOrWhiteSpace(notice.Message))
+ {
+ _logger.LogDebug("No active notice");
+ return new NoticeResult(false, null, null);
+ }
+
+ if (notice.ExpiresAt.HasValue && notice.ExpiresAt.Value <= DateTimeOffset.UtcNow)
+ {
+ _logger.LogDebug("Notice expired at {ExpiresAt}", notice.ExpiresAt);
+ return new NoticeResult(false, null, null);
+ }
+
+ // If the user is already on a version that meets the minimum, suppress the notice
+ if (!string.IsNullOrWhiteSpace(notice.MinimumVersion))
+ {
+ try
+ {
+ var current = VersionCheckHelper.ParseVersion(_currentVersion);
+ var minimum = VersionCheckHelper.ParseVersion(notice.MinimumVersion);
+ if (current >= minimum)
+ {
+ _logger.LogDebug("Current version {Current} meets minimum {Minimum}; notice suppressed", _currentVersion, notice.MinimumVersion);
+ return new NoticeResult(false, null, null);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Version comparison failed; showing notice as a precaution");
+ }
+ }
+
+ // Use minimumVersion to determine --prerelease flag when specified;
+ // fall back to current version (e.g. notice has no minimumVersion).
+ var versionForCommand = string.IsNullOrWhiteSpace(notice.MinimumVersion)
+ ? _currentVersion
+ : notice.MinimumVersion;
+ var updateCommand = VersionCheckHelper.GetUpdateCommand(versionForCommand);
+
+ return new NoticeResult(true, notice.Message, updateCommand);
+ }
+ catch (OperationCanceledException)
+ {
+ _logger.LogDebug("Notice check cancelled");
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Notice check failed: {Message}", ex.Message);
+ return new NoticeResult(false, null, null);
+ }
+ }
+
+ private async Task GetNoticeWithCacheAsync(CancellationToken cancellationToken)
+ {
+ var cacheFilePath = GetCacheFilePath();
+ var cached = TryLoadCache(cacheFilePath);
+
+ if (cached != null && DateTimeOffset.UtcNow - cached.CachedAt < TimeSpan.FromHours(CacheTtlHours))
+ {
+ _logger.LogDebug("Using cached notice (cached at {CachedAt})", cached.CachedAt);
+ return cached.ActiveNotice;
+ }
+
+ var notice = await FetchFromServerAsync(cancellationToken);
+ SaveCache(cacheFilePath, new NoticeCache(DateTimeOffset.UtcNow, notice));
+ return notice;
+ }
+
+ private async Task FetchFromServerAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ using var httpClient = HttpClientFactory.CreateAuthenticatedClient(authToken: null);
+ using var response = await httpClient.GetAsync(NoticesUrl, cancellationToken);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ _logger.LogDebug("Notices endpoint returned {StatusCode}", response.StatusCode);
+ return null;
+ }
+
+ var content = await response.Content.ReadAsStringAsync(cancellationToken);
+ var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
+ return JsonSerializer.Deserialize(content, options);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ _logger.LogDebug(ex, "Failed to fetch notices from server");
+ return null;
+ }
+ }
+
+ private NoticeCache? TryLoadCache(string path)
+ {
+ try
+ {
+ if (!File.Exists(path))
+ return null;
+
+ var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
+ return JsonSerializer.Deserialize(File.ReadAllText(path), options);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Could not load notice cache from {Path}", path);
+ return null;
+ }
+ }
+
+ private void SaveCache(string path, NoticeCache cache)
+ {
+ try
+ {
+ var dir = Path.GetDirectoryName(path);
+ if (!string.IsNullOrEmpty(dir))
+ Directory.CreateDirectory(dir);
+
+ File.WriteAllText(path, JsonSerializer.Serialize(cache));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Could not save notice cache to {Path}", path);
+ }
+ }
+
+ ///
+ /// Returns the path to the on-disk notice cache file.
+ ///
+ internal static string GetCacheFilePath()
+ => Path.Combine(ConfigService.GetGlobalConfigDirectory(), CacheFileName);
+}
diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/VersionCheckService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/VersionCheckService.cs
index 78b88006..1de90fd4 100644
--- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/VersionCheckService.cs
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/VersionCheckService.cs
@@ -9,12 +9,15 @@
namespace Microsoft.Agents.A365.DevTools.Cli.Services;
///
-/// Service for checking if newer versions of the CLI are available on NuGet.
+/// Checks NuGet for a newer version of the CLI and returns an update prompt when one is available.
+/// Results are cached locally for hours to avoid a NuGet API call
+/// on every invocation.
///
public class VersionCheckService : IVersionCheckService
{
private const string NuGetApiUrl = "https://api.nuget.org/v3-flatcontainer/microsoft.agents.a365.devtools.cli/index.json";
- private const string PackageId = "Microsoft.Agents.A365.DevTools.Cli";
+ private const string CacheFileName = "version.cache.json";
+ private const int CacheTtlHours = 24;
private readonly ILogger _logger;
private readonly string _currentVersion;
@@ -30,17 +33,23 @@ public async Task CheckForUpdatesAsync(CancellationToken can
{
try
{
- // Skip check in CI/CD environments
- if (IsRunningInCiCd())
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (VersionCheckHelper.IsRunningInCiCd())
{
_logger.LogDebug("Skipping version check in CI/CD environment");
return new VersionCheckResult(false, _currentVersion, null, null);
}
- _logger.LogDebug("Checking for updates from NuGet...");
+ _logger.LogDebug("Checking for updates...");
- // Query NuGet API for available versions
- var latestVersion = await GetLatestVersionFromNuGetAsync(cancellationToken);
+ var latestVersion = GetCachedLatestVersion();
+ if (latestVersion == null)
+ {
+ latestVersion = await GetLatestVersionFromNuGetAsync(cancellationToken);
+ if (latestVersion != null)
+ SaveCache(new VersionCheckCache(DateTimeOffset.UtcNow, latestVersion));
+ }
if (latestVersion == null)
{
@@ -48,27 +57,20 @@ public async Task CheckForUpdatesAsync(CancellationToken can
return new VersionCheckResult(false, _currentVersion, null, null);
}
- // Compare versions
var updateAvailable = IsNewerVersion(_currentVersion, latestVersion);
if (updateAvailable)
- {
- _logger.LogDebug("Update available: {LatestVersion} (current: {CurrentVersion})", latestVersion, _currentVersion);
- }
+ _logger.LogDebug("Update available: {Latest} (current: {Current})", latestVersion, _currentVersion);
else
- {
- _logger.LogDebug("Running latest version: {CurrentVersion}", _currentVersion);
- }
-
- // Generate update command based on whether the latest version is a preview
- var updateCommand = GetUpdateCommand(latestVersion);
+ _logger.LogDebug("Running latest version: {Current}", _currentVersion);
- return new VersionCheckResult(updateAvailable, _currentVersion, latestVersion, updateCommand);
+ return new VersionCheckResult(updateAvailable, _currentVersion, latestVersion,
+ VersionCheckHelper.GetUpdateCommand(latestVersion));
}
catch (OperationCanceledException)
{
- _logger.LogDebug("Version check cancelled (timeout or user requested)");
- throw; // Re-throw to let caller handle timeout
+ _logger.LogDebug("Version check cancelled");
+ throw;
}
catch (Exception ex)
{
@@ -77,58 +79,37 @@ public async Task CheckForUpdatesAsync(CancellationToken can
}
}
- ///
- /// Queries the NuGet V3 API to get the latest version of the package.
- ///
- ///
- /// Uses HttpClientFactory.CreateAuthenticatedClient, which is the established pattern
- /// in this codebase for creating HTTP clients. This is a static factory method that
- /// properly configures and returns a new HttpClient instance.
- ///
private async Task GetLatestVersionFromNuGetAsync(CancellationToken cancellationToken)
{
try
{
using var httpClient = HttpClientFactory.CreateAuthenticatedClient(authToken: null);
-
using var response = await httpClient.GetAsync(NuGetApiUrl, cancellationToken);
if (!response.IsSuccessStatusCode)
{
- _logger.LogDebug("NuGet API returned status code: {StatusCode}", response.StatusCode);
+ _logger.LogDebug("NuGet API returned {StatusCode}", response.StatusCode);
return null;
}
var content = await response.Content.ReadAsStringAsync(cancellationToken);
-
- var options = new JsonSerializerOptions
- {
- PropertyNameCaseInsensitive = true
- };
-
+ var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
var versionResponse = JsonSerializer.Deserialize(content, options);
-
+
if (versionResponse?.Versions == null || versionResponse.Versions.Length == 0)
{
_logger.LogDebug("No versions found in NuGet response");
return null;
}
- // Sort versions semantically and return the latest
- // NuGet API typically returns versions in chronological order, but we sort to be safe
- var sortedVersions = versionResponse.Versions
- .Select(v => new { Original = v, Parsed = TryParseVersion(v) })
+ // Sort semantically — NuGet returns chronological order, but we sort to be safe
+ var sorted = versionResponse.Versions
+ .Select(v => new { Original = v, Parsed = VersionCheckHelper.TryParseVersion(v) })
.Where(v => v.Parsed != null)
.OrderByDescending(v => v.Parsed)
.ToList();
- if (sortedVersions.Count == 0)
- {
- _logger.LogDebug("No valid versions found in NuGet response");
- return null;
- }
-
- return sortedVersions[0].Original;
+ return sorted.Count == 0 ? null : sorted[0].Original;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
@@ -137,145 +118,69 @@ public async Task CheckForUpdatesAsync(CancellationToken can
}
}
- ///
- /// Compares two semantic versions to determine if the latest is newer than current.
- ///
- /// Current version string.
- /// Latest version string from NuGet.
- /// True if latest is newer than current.
- private bool IsNewerVersion(string currentVersion, string latestVersion)
+ private bool IsNewerVersion(string current, string latest)
{
try
{
- // Parse semantic versions
- var current = ParseVersion(currentVersion);
- var latest = ParseVersion(latestVersion);
-
- // Compare versions
- return latest > current;
+ return VersionCheckHelper.ParseVersion(latest) > VersionCheckHelper.ParseVersion(current);
}
catch (Exception ex)
{
- _logger.LogDebug(ex, "Failed to compare versions: current={Current}, latest={Latest}", currentVersion, latestVersion);
+ _logger.LogDebug(ex, "Failed to compare versions: current={Current}, latest={Latest}", current, latest);
return false;
}
}
- ///
- /// Parses a semantic version string into a comparable Version object.
- /// Handles formats like "1.1.0-preview.123+git.hash".
- ///
- internal Version ParseVersion(string versionString)
- {
- var parsed = TryParseVersion(versionString);
- if (parsed == null)
- {
- throw new FormatException($"Invalid version format: {versionString}");
- }
- return parsed;
- }
-
- ///
- /// Tries to parse a semantic version string into a comparable Version object.
- /// Returns null if parsing fails.
- ///
- /// Note: This parsing treats preview versions as comparable by their preview number.
- /// Handles two formats:
- /// - "1.1.52-preview" (version number includes preview iteration)
- /// - "1.1.0-preview.50" (preview number is separate)
- ///
- private Version? TryParseVersion(string versionString)
+ private string? GetCachedLatestVersion()
{
try
{
- // Remove any build metadata (+...)
- var cleanVersion = versionString.Split('+')[0];
-
- // For preview versions, extract the version number
- if (cleanVersion.Contains('-'))
- {
- var parts = cleanVersion.Split('-');
- var baseVersion = parts[0]; // e.g., "1.1.52" or "1.1.0"
+ var path = GetCacheFilePath();
+ if (!File.Exists(path))
+ return null;
- // Check if there's a preview number after the dash
- if (parts.Length > 1)
- {
- var previewPart = parts[1]; // e.g., "preview" or "preview.50"
+ var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
+ var cache = JsonSerializer.Deserialize(File.ReadAllText(path), options);
- // Format 1: "1.1.0-preview.50" - append preview number as revision
- if (previewPart.StartsWith("preview.") && previewPart.Length > 8)
- {
- var previewNumber = previewPart.Substring(8); // Get number after "preview."
- cleanVersion = int.TryParse(previewNumber, out var preview)
- ? $"{baseVersion}.{preview}"
- : baseVersion; // If parsing fails, just use base version
- }
- // Format 2: "1.1.52-preview" - version already includes iteration number
- else
- {
- cleanVersion = baseVersion;
- }
- }
- else
- {
- cleanVersion = baseVersion;
- }
- }
+ if (cache == null)
+ return null;
- // Ensure we have at least 3 components for Version constructor
- var versionParts = cleanVersion.Split('.');
- var componentsNeeded = 3 - versionParts.Length;
- for (var i = 0; i < componentsNeeded; i++)
+ if (DateTimeOffset.UtcNow - cache.CachedAt >= TimeSpan.FromHours(CacheTtlHours))
{
- cleanVersion += ".0";
+ _logger.LogDebug("Version cache expired (cached at {CachedAt})", cache.CachedAt);
+ return null;
}
- return new Version(cleanVersion);
+ _logger.LogDebug("Using cached version {Version} (cached at {CachedAt})", cache.LatestVersion, cache.CachedAt);
+ return cache.LatestVersion;
}
- catch
+ catch (Exception ex)
{
+ _logger.LogDebug(ex, "Could not load version cache");
return null;
}
}
- ///
- /// Generates the appropriate update command based on the version type.
- ///
- /// The version string to check for preview status.
- /// The dotnet tool update command with or without --prerelease flag.
- private static string GetUpdateCommand(string version)
+ private void SaveCache(VersionCheckCache cache)
{
- var baseCommand = $"dotnet tool update -g {PackageId}";
+ try
+ {
+ var path = GetCacheFilePath();
+ var dir = Path.GetDirectoryName(path);
+ if (!string.IsNullOrEmpty(dir))
+ Directory.CreateDirectory(dir);
- // If the version contains "preview", add the --prerelease flag
- if (version.Contains("preview", StringComparison.OrdinalIgnoreCase))
+ File.WriteAllText(path, JsonSerializer.Serialize(cache));
+ }
+ catch (Exception ex)
{
- return $"{baseCommand} --prerelease";
+ _logger.LogDebug(ex, "Could not save version cache");
}
-
- return baseCommand;
}
///
- /// Detects if the CLI is running in a CI/CD environment.
+ /// Returns the path to the on-disk version cache file.
///
- private static bool IsRunningInCiCd()
- {
- // Common CI/CD environment variables
- var ciEnvVars = new[]
- {
- "CI", // Generic CI indicator
- "TF_BUILD", // Azure DevOps
- "GITHUB_ACTIONS", // GitHub Actions
- "JENKINS_HOME", // Jenkins
- "GITLAB_CI", // GitLab CI
- "CIRCLECI", // CircleCI
- "TRAVIS", // Travis CI
- "TEAMCITY_VERSION", // TeamCity
- "BUILDKITE", // Buildkite
- "CODEBUILD_BUILD_ID" // AWS CodeBuild
- };
-
- return ciEnvVars.Any(envVar => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(envVar)));
- }
+ internal static string GetCacheFilePath()
+ => Path.Combine(ConfigService.GetGlobalConfigDirectory(), CacheFileName);
}
diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/NoticeServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/NoticeServiceTests.cs
new file mode 100644
index 00000000..11800040
--- /dev/null
+++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/NoticeServiceTests.cs
@@ -0,0 +1,218 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using FluentAssertions;
+using Microsoft.Agents.A365.DevTools.Cli.Models;
+using Microsoft.Agents.A365.DevTools.Cli.Services;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+using System.Text.Json;
+using Xunit;
+
+namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Services;
+
+[Collection("VersionCheckTests")]
+public class NoticeServiceTests : IDisposable
+{
+ private readonly ILogger _logger;
+ private readonly NoticeService _service;
+
+ public NoticeServiceTests()
+ {
+ _logger = Substitute.For>();
+ _service = new NoticeService(_logger);
+ }
+
+ public void Dispose()
+ {
+ // Clean up cache file written by tests so state does not leak
+ var path = NoticeService.GetCacheFilePath();
+ if (File.Exists(path))
+ File.Delete(path);
+ }
+
+ // ---------------------------------------------------------------------------
+ // CI/CD skip
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public async Task CheckForNoticeAsync_WhenRunningInCiCd_ReturnsNoNotice()
+ {
+ Environment.SetEnvironmentVariable("CI", "true");
+ try
+ {
+ var result = await _service.CheckForNoticeAsync();
+ result.HasNotice.Should().BeFalse();
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable("CI", null);
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Notice evaluation (cache-injected to avoid real network calls)
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public async Task CheckForNoticeAsync_WhenCacheHasNoMessage_ReturnsNoNotice()
+ {
+ WriteCacheFile(new NoticeCache(DateTimeOffset.UtcNow, new Notice(null, null, null)));
+
+ var result = await _service.CheckForNoticeAsync();
+
+ result.HasNotice.Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task CheckForNoticeAsync_WhenNoticeIsExpired_ReturnsNoNotice()
+ {
+ WriteCacheFile(new NoticeCache(
+ DateTimeOffset.UtcNow,
+ new Notice("Critical issue!", null, DateTimeOffset.UtcNow.AddDays(-1))));
+
+ var result = await _service.CheckForNoticeAsync();
+
+ result.HasNotice.Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task CheckForNoticeAsync_WhenNoticeHasNoExpiry_ReturnsNotice()
+ {
+ ClearCiEnvironment();
+ try
+ {
+ WriteCacheFile(new NoticeCache(
+ DateTimeOffset.UtcNow,
+ new Notice("Critical issue - upgrade now.", null, null)));
+
+ var result = await _service.CheckForNoticeAsync();
+
+ result.HasNotice.Should().BeTrue();
+ result.Message.Should().Be("Critical issue - upgrade now.");
+ }
+ finally
+ {
+ RestoreCiEnvironment();
+ }
+ }
+
+ [Fact]
+ public async Task CheckForNoticeAsync_WhenNoticeHasFutureExpiry_ReturnsNotice()
+ {
+ ClearCiEnvironment();
+ try
+ {
+ WriteCacheFile(new NoticeCache(
+ DateTimeOffset.UtcNow,
+ new Notice("Security advisory.", null, DateTimeOffset.UtcNow.AddDays(30))));
+
+ var result = await _service.CheckForNoticeAsync();
+
+ result.HasNotice.Should().BeTrue();
+ result.Message.Should().Be("Security advisory.");
+ }
+ finally
+ {
+ RestoreCiEnvironment();
+ }
+ }
+
+ [Fact]
+ public async Task CheckForNoticeAsync_WhenCurrentVersionMeetsMinimum_ReturnsNoNotice()
+ {
+ // Any real build version is above 0.0.1
+ WriteCacheFile(new NoticeCache(
+ DateTimeOffset.UtcNow,
+ new Notice("Please upgrade.", "0.0.1", null)));
+
+ var result = await _service.CheckForNoticeAsync();
+
+ result.HasNotice.Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task CheckForNoticeAsync_WhenCurrentVersionBelowMinimum_ReturnsNotice()
+ {
+ ClearCiEnvironment();
+ try
+ {
+ // Any realistic build version is below 99.99.99
+ WriteCacheFile(new NoticeCache(
+ DateTimeOffset.UtcNow,
+ new Notice("Please upgrade to v99.99.99.", "99.99.99", null)));
+
+ var result = await _service.CheckForNoticeAsync();
+
+ result.HasNotice.Should().BeTrue();
+ result.Message.Should().Be("Please upgrade to v99.99.99.");
+ result.UpdateCommand.Should().Contain("dotnet tool update")
+ .And.Contain("Microsoft.Agents.A365.DevTools.Cli");
+ }
+ finally
+ {
+ RestoreCiEnvironment();
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Cache file path
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public void GetCacheFilePath_ReturnsPathWithExpectedFileName()
+ {
+ Path.GetFileName(NoticeService.GetCacheFilePath()).Should().Be("notice.cache.json");
+ }
+
+ // ---------------------------------------------------------------------------
+ // Cancellation
+ // ---------------------------------------------------------------------------
+
+ [Fact]
+ public async Task CheckForNoticeAsync_WhenCancelled_ThrowsOperationCanceledException()
+ {
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ Func act = async () => await _service.CheckForNoticeAsync(cts.Token);
+
+ await act.Should().ThrowAsync();
+ }
+
+ // ---------------------------------------------------------------------------
+ // Helpers
+ // ---------------------------------------------------------------------------
+
+ private static void WriteCacheFile(NoticeCache cache)
+ {
+ var path = NoticeService.GetCacheFilePath();
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ File.WriteAllText(path, JsonSerializer.Serialize(cache));
+ }
+
+ // CI env vars that IsRunningInCiCd() checks — cleared so notice-display tests pass in CI.
+ private static readonly string[] CiEnvVars =
+ [
+ "CI", "TF_BUILD", "GITHUB_ACTIONS", "JENKINS_HOME", "GITLAB_CI",
+ "CIRCLECI", "TRAVIS", "TEAMCITY_VERSION", "BUILDKITE", "CODEBUILD_BUILD_ID"
+ ];
+
+ private readonly Dictionary _savedCiEnv = new();
+
+ private void ClearCiEnvironment()
+ {
+ foreach (var key in CiEnvVars)
+ {
+ _savedCiEnv[key] = Environment.GetEnvironmentVariable(key);
+ Environment.SetEnvironmentVariable(key, null);
+ }
+ }
+
+ private void RestoreCiEnvironment()
+ {
+ foreach (var (key, value) in _savedCiEnv)
+ Environment.SetEnvironmentVariable(key, value);
+ _savedCiEnv.Clear();
+ }
+}
diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/VersionCheckServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/VersionCheckServiceTests.cs
index e58e8b88..c9c79151 100644
--- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/VersionCheckServiceTests.cs
+++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/VersionCheckServiceTests.cs
@@ -3,6 +3,7 @@
using FluentAssertions;
using Microsoft.Agents.A365.DevTools.Cli.Services;
+using Microsoft.Agents.A365.DevTools.Cli.Services.Internal;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
@@ -94,9 +95,9 @@ public async Task CheckForUpdatesAsync_WithTimeout_HandlesGracefully()
[InlineData("1.1.0-preview.100", "1.1.0-preview.50", false)] // Current preview is newer
public void ParseVersion_ComparesVersionsCorrectly(string current, string latest, bool expectedNewerAvailable)
{
- // Act - ParseVersion is internal, accessible to test assembly
- var currentVersion = _versionCheckService.ParseVersion(current);
- var latestVersion = _versionCheckService.ParseVersion(latest);
+ // Act - ParseVersion moved to VersionCheckHelper (internal, accessible to test assembly)
+ var currentVersion = VersionCheckHelper.ParseVersion(current);
+ var latestVersion = VersionCheckHelper.ParseVersion(latest);
var isNewer = latestVersion > currentVersion;