diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs
index 98354449..0c9acb47 100644
--- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs
+++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs
@@ -108,8 +108,19 @@ public static Command CreateCommand(
command.AddOption(mosPersonalTokenOption);
command.AddOption(verboseOption);
- command.SetHandler(async (bool dryRun, bool skipGraph, string mosEnv, string? mosPersonalToken, bool verbose) =>
+ command.SetHandler(async (System.CommandLine.Invocation.InvocationContext context) =>
{
+ // Extract options from invocation context (enables context.ExitCode on error paths)
+ var dryRun = context.ParseResult.GetValueForOption(dryRunOption);
+ var skipGraph = context.ParseResult.GetValueForOption(skipGraphOption);
+ var mosEnv = context.ParseResult.GetValueForOption(mosEnvOption) ?? "prod";
+ var mosPersonalToken = context.ParseResult.GetValueForOption(mosPersonalTokenOption);
+ var verbose = context.ParseResult.GetValueForOption(verboseOption);
+
+ // Track whether the command completed normally (success or expected early exit)
+ // All unhandled error paths will set context.ExitCode = 1
+ var isNormalExit = false;
+
// Generate correlation ID at workflow entry point
var correlationId = HttpClientFactory.GenerateCorrelationId();
@@ -182,6 +193,7 @@ public static Command CreateCommand(
logger.LogInformation("DRY RUN: Updated manifest (not saved):\n{Json}", updatedManifest);
logger.LogInformation("DRY RUN: Updated agentic user manifest template (not saved):\n{Json}", updatedAgenticUserManifestTemplate);
logger.LogInformation("DRY RUN: Skipping zipping & API calls");
+ isNormalExit = true;
return;
}
@@ -621,12 +633,17 @@ public static Command CreateCommand(
if (skipGraph)
{
logger.LogInformation("--skip-graph specified; skipping federated identity credential and role assignment.");
+ isNormalExit = true;
return;
}
if (string.IsNullOrWhiteSpace(tenantId))
{
logger.LogWarning("tenantId unavailable; skipping Graph operations.");
+ // Treat as normal exit (exit code 0) because MOS publish completed successfully
+ // and Graph operations are optional. Users who need Graph operations should ensure
+ // tenantId is configured or use --skip-graph explicitly.
+ isNormalExit = true;
return;
}
@@ -647,12 +664,24 @@ public static Command CreateCommand(
}
logger.LogInformation("Publish completed successfully!");
+ isNormalExit = true;
}
catch (Exception ex)
{
logger.LogError(ex, "Publish command failed: {Message}", ex.Message);
}
- }, dryRunOption, skipGraphOption, mosEnvOption, mosPersonalTokenOption, verboseOption);
+ finally
+ {
+ // Set exit code 1 for all error paths (different from ConfigCommand's per-site approach,
+ // but more robust as it catches all error returns and exceptions automatically).
+ // This ensures any error path that doesn't explicitly set isNormalExit=true will
+ // return exit code 1, preventing the bug where ~27 error paths returned 0.
+ if (!isNormalExit)
+ {
+ context.ExitCode = 1;
+ }
+ }
+ });
return command;
}
diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PublishCommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PublishCommandTests.cs
new file mode 100644
index 00000000..0a01075a
--- /dev/null
+++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PublishCommandTests.cs
@@ -0,0 +1,408 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.CommandLine;
+using FluentAssertions;
+using Microsoft.Agents.A365.DevTools.Cli.Commands;
+using Microsoft.Agents.A365.DevTools.Cli.Models;
+using Microsoft.Agents.A365.DevTools.Cli.Services;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using NSubstitute;
+using Xunit;
+
+namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands;
+
+///
+/// Tests for PublishCommand exit code behavior.
+/// These tests verify that error paths return exit code 1 and normal paths return exit code 0.
+///
+public class PublishCommandTests
+{
+ private readonly ILogger _logger;
+ private readonly IConfigService _configService;
+ private readonly AgentPublishService _agentPublishService;
+ private readonly GraphApiService _graphApiService;
+ private readonly AgentBlueprintService _blueprintService;
+ private readonly ManifestTemplateService _manifestTemplateService;
+
+ public PublishCommandTests()
+ {
+ _logger = Substitute.For>();
+ _configService = Substitute.For();
+
+ // For concrete classes, create partial substitutes with correct constructor parameters
+ // GraphApiService has a parameterless constructor
+ _graphApiService = Substitute.ForPartsOf();
+
+ // AgentPublishService needs (ILogger, GraphApiService)
+ _agentPublishService = Substitute.ForPartsOf(
+ Substitute.For>(),
+ _graphApiService);
+
+ // AgentBlueprintService needs (ILogger, GraphApiService)
+ _blueprintService = Substitute.ForPartsOf(
+ Substitute.For>(),
+ _graphApiService);
+
+ // ManifestTemplateService needs only ILogger
+ _manifestTemplateService = Substitute.ForPartsOf(
+ Substitute.For>());
+ }
+
+ [Fact]
+ public async Task PublishCommand_WithMissingBlueprintId_ShouldReturnExitCode1()
+ {
+ // Arrange - Return config with missing blueprintId (this is an error path)
+ var config = new Agent365Config
+ {
+ AgentBlueprintId = null, // Missing blueprintId triggers error
+ TenantId = "test-tenant",
+ AgentBlueprintDisplayName = "Test Agent"
+ };
+ _configService.LoadAsync().Returns(config);
+
+ var command = PublishCommand.CreateCommand(
+ _logger,
+ _configService,
+ _agentPublishService,
+ _graphApiService,
+ _blueprintService,
+ _manifestTemplateService);
+
+ var root = new RootCommand();
+ root.AddCommand(command);
+
+ // Act
+ var exitCode = await root.InvokeAsync("publish");
+
+ // Assert
+ exitCode.Should().Be(1, "missing blueprintId should return exit code 1");
+
+ // Verify error was logged
+ _logger.Received().Log(
+ LogLevel.Error,
+ Arg.Any(),
+ Arg.Is