Skip to content
1 change: 1 addition & 0 deletions release_notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@

- Fix `AzureFunctionsJobHost__logging__logLevel__Function` override from `local.settings.json` being ignored due to the host pre-setting the environment variable before user configuration was loaded (#4815)
- Fix `ArgumentNullException: Value cannot be null. (Parameter 'input')` during `func azure functionapp publish` for non-.NET runtimes (PowerShell, Node.js, Python, Java) on Windows function apps (#4822)
- Fix `func pack` throwing cryptic `Unsupported runtime: None` when `local.settings.json` is absent and `FUNCTIONS_WORKER_RUNTIME` is not set. The command now emits a clear, actionable error. The existing `.dll`-based fallback for `--no-build` is preserved but scoped to top-level files only to avoid false positives (#4829)
25 changes: 20 additions & 5 deletions src/Cli/func/Actions/LocalActions/PackAction/PackAction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
using Azure.Functions.Cli.Common;
using Azure.Functions.Cli.Helpers;
using Azure.Functions.Cli.Interfaces;
using Colors.Net;
using Fclp;
using static Azure.Functions.Cli.Common.OutputTheme;

namespace Azure.Functions.Cli.Actions.LocalActions.PackAction
{
Expand Down Expand Up @@ -83,20 +85,33 @@ public override async Task RunAsync()
Environment.CurrentDirectory = Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, FolderPath));
}

// Detect the runtime and set the runtime
// Detect the runtime from environment variable or local.settings.json
var workerRuntime = WorkerRuntimeLanguageHelper.GetCurrentWorkerRuntimeLanguage(_secretsManager, refreshSecrets: true);

// If no runtime is detected and NoBuild is true, check for .dll files to infer .NET runtime
// This is because when we run dotnet publish, there is no local.settings.json anymore to determine runtime.
if (workerRuntime == WorkerRuntime.None && NoBuild)
{
var files = Directory.GetFiles(FolderPath, "*.dll", SearchOption.AllDirectories);
if (files.Length > 0)
// Narrow to top-level only to avoid false positives in node_modules/, venvs, etc.
var dlls = Directory.GetFiles(Environment.CurrentDirectory, "*.dll", SearchOption.TopDirectoryOnly);
if (dlls.Length > 0)
{
workerRuntime = WorkerRuntime.Dotnet;
ColoredConsole.WriteLine(WarningColor(
$"Warning: No '{Constants.FunctionsWorkerRuntime}' setting found. " +
$"Defaulting to '{WorkerRuntimeLanguageHelper.GetRuntimeMoniker(WorkerRuntime.Dotnet)}' " +
$"based on .dll files. This fallback is deprecated and will be removed in a future release. " +
$"Set '{Constants.FunctionsWorkerRuntime}' in '{Constants.LocalSettingsJsonFileName}' " +
$"or as an environment variable."));
}
}

if (workerRuntime == WorkerRuntime.None)
{
throw new CliException(
$"Unable to determine the worker runtime for this project. " +
$"Set the '{Constants.FunctionsWorkerRuntime}' value in '{Constants.LocalSettingsJsonFileName}' or as an environment variable. " +
$"Valid values: {WorkerRuntimeLanguageHelper.AvailableWorkersRuntimeString}.");
}

GlobalCoreToolsSettings.CurrentWorkerRuntime = workerRuntime;

// Switch back to original directory after detecting runtime to package app in the correct context
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.

using Azure.Functions.Cli.Common;
using Azure.Functions.Cli.Interfaces;
using FluentAssertions;
using Moq;
using Xunit;
using FuncPackAction = Azure.Functions.Cli.Actions.LocalActions.PackAction.PackAction;

namespace Azure.Functions.Cli.UnitTests.ActionsTests.PackAction
{
public class PackActionRuntimeDetectionTests : IDisposable
{
private readonly string _tempDir;
private readonly string _originalDirectory;
private readonly Mock<ISecretsManager> _mockSecretsManager;

public PackActionRuntimeDetectionTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
Directory.CreateDirectory(_tempDir);
_originalDirectory = Environment.CurrentDirectory;

// Simulate absent local.settings.json: GetSecrets returns empty dict
_mockSecretsManager = new Mock<ISecretsManager>();
_mockSecretsManager
.Setup(s => s.GetSecrets(It.IsAny<bool>()))
.Returns(new Dictionary<string, string>());

// Write host.json so PackHelpers.ValidateFunctionAppRoot passes
File.WriteAllText(Path.Combine(_tempDir, "host.json"), "{}");

// Ensure FUNCTIONS_WORKER_RUNTIME env var is not set
Environment.SetEnvironmentVariable(Constants.FunctionsWorkerRuntime, null);
}

public void Dispose()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you fix this

public void Dispose()
{
    try
    {
        Directory.Delete(_tempDir, recursive: true);
    }
    catch (IOException)
    {
        // Best-effort cleanup; files may still be locked by the test host.
    }
}

Alternatively, ensure all IDisposable resources are fully disposed before Dispose() runs.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've checked and there should no disposable objects currently in the test. Code wise it is adjusted with the reset mechanism for the current environment variable usage

{
Environment.CurrentDirectory = _originalDirectory;
Environment.SetEnvironmentVariable(Constants.FunctionsWorkerRuntime, null);

try
{
Directory.Delete(_tempDir, recursive: true);
}
catch (IOException)
{
// Best-effort cleanup; files may still be locked by the test host.
}
}

[Fact]
public async Task RunAsync_NoSettingsAndNoProjectFiles_ThrowsCliExceptionWithActionableMessage()
{
// Arrange: _tempDir has only host.json — no runtime signals
Environment.CurrentDirectory = _tempDir;
var action = new FuncPackAction(_mockSecretsManager.Object);

// Act & Assert
var ex = await Assert.ThrowsAsync<CliException>(() => action.RunAsync());
ex.Message.Should().Contain("Unable to determine the worker runtime");
ex.Message.Should().Contain(Constants.FunctionsWorkerRuntime);
ex.Message.Should().Contain(Constants.LocalSettingsJsonFileName);
}

[Fact]
public async Task RunAsync_WorkerRuntimeSetInEnvVar_DoesNotThrowRuntimeError()
{
// Arrange: env var is set — inference path is never reached
Environment.SetEnvironmentVariable(Constants.FunctionsWorkerRuntime, "dotnet-isolated");
Environment.CurrentDirectory = _tempDir;
var action = new FuncPackAction(_mockSecretsManager.Object);

// Act & Assert: must NOT throw the "Unable to determine" CliException.
// The action will still fail (no .csproj to build), but that's a different error.
var ex = await Assert.ThrowsAsync<CliException>(() => action.RunAsync());
ex.Message.Should().NotContain("Unable to determine the worker runtime");
}

[Fact]
public async Task RunAsync_NoBuildWithDllFiles_DoesNotThrowRuntimeError()
{
// Arrange: --no-build mode with a top-level .dll present (legacy dotnet in-proc build output)
File.WriteAllText(Path.Combine(_tempDir, "MyApp.dll"), string.Empty);
Environment.CurrentDirectory = _tempDir;
var action = new FuncPackAction(_mockSecretsManager.Object);
action.ParseArgs(["--no-build"]);

// Act & Assert: runtime detection succeeds, packing completes (no exception thrown).
// The .dll fallback sets Dotnet runtime, and --no-build packs the current directory.
Exception thrownException = null;
try
{
await action.RunAsync();
}
catch (Exception ex)
{
thrownException = ex;
}

thrownException?.Message.Should().NotContain("Unable to determine the worker runtime");
}

[Fact]
public async Task RunAsync_NoBuildWithNoDllFiles_ThrowsCliExceptionWithActionableMessage()
{
// Arrange: --no-build with no .dll files and no runtime signals
Environment.CurrentDirectory = _tempDir;
var action = new FuncPackAction(_mockSecretsManager.Object);
action.ParseArgs(["--no-build"]);

// Act & Assert
var ex = await Assert.ThrowsAsync<CliException>(() => action.RunAsync());
ex.Message.Should().Contain("Unable to determine the worker runtime");
}

[Fact]
public async Task RunAsync_WorkerRuntimeSetInLocalSettings_DoesNotThrowRuntimeError()
{
// Arrange: local.settings.json contains FUNCTIONS_WORKER_RUNTIME (simulated via secrets manager)
_mockSecretsManager
.Setup(s => s.GetSecrets(It.IsAny<bool>()))
.Returns(new Dictionary<string, string>
{
[Constants.FunctionsWorkerRuntime] = "dotnet-isolated"
});
Environment.CurrentDirectory = _tempDir;
var action = new FuncPackAction(_mockSecretsManager.Object);

// Act & Assert: must NOT throw the "Unable to determine" CliException.
// The action will still fail (no .csproj to build), but that's a different error.
var ex = await Assert.ThrowsAsync<CliException>(() => action.RunAsync());
ex.Message.Should().NotContain("Unable to determine the worker runtime");
}
}
}