Skip to content
Open
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
117 changes: 117 additions & 0 deletions .github/workflows/dep-version-validation.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# Validates that dependency version changes do not break the DTFx NuGet packages.
# Packs DurableTask.Core and DurableTask.Emulator from source, builds a console
# app using the local packages, runs a HelloCities orchestration with the
# Emulator backend, and validates orchestration output.
name: Dependency Version Validation

on:
push:
branches: [ main ]
paths:
- 'Directory.Packages.props'
- 'tools/DurableTask.props'
- 'Test/SmokeTests/**'
pull_request:
paths:
- 'Directory.Packages.props'
- 'tools/DurableTask.props'
- 'Test/SmokeTests/**'
workflow_dispatch:

permissions:
contents: read

jobs:
dep-validation-smoke-test:
name: 'Emulator Orchestration Smoke Test (NuGet Packages)'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with: { dotnet-version: '8.0.x' }

- name: Parse DTFx package versions
id: version
run: |
set -e
get_version() {
local v
v=$(dotnet msbuild "$1" -nologo -getProperty:PackageVersion 2>/dev/null | tail -n 1 | tr -d '\r')
[ -z "$v" ] && v=$(dotnet msbuild "$1" -nologo -getProperty:Version 2>/dev/null | tail -n 1 | tr -d '\r')
[ -z "$v" ] && { echo "FAIL: Cannot resolve version for $1" >&2; exit 1; }
echo "$v"
}
CORE_VERSION=$(get_version "src/DurableTask.Core/DurableTask.Core.csproj")
EMULATOR_VERSION=$(get_version "src/DurableTask.Emulator/DurableTask.Emulator.csproj")
echo "Core: $CORE_VERSION, Emulator: $EMULATOR_VERSION"
echo "core_version=${CORE_VERSION}" >> "$GITHUB_OUTPUT"
echo "emulator_version=${EMULATOR_VERSION}" >> "$GITHUB_OUTPUT"

- name: Pack DTFx packages
run: |
set -e
PKG=Test/SmokeTests/EmulatorSmokeTest/local-packages; mkdir -p "$PKG"
dotnet build src/DurableTask.Core/DurableTask.Core.csproj -c Release
dotnet build src/DurableTask.Emulator/DurableTask.Emulator.csproj -c Release
dotnet pack src/DurableTask.Core/DurableTask.Core.csproj -c Release --no-build --output "$PKG"
dotnet pack src/DurableTask.Emulator/DurableTask.Emulator.csproj -c Release --no-build --output "$PKG"
ls -la "$PKG"

- name: Verify packed packages
env:
CORE_VERSION: ${{ steps.version.outputs.core_version }}
EMULATOR_VERSION: ${{ steps.version.outputs.emulator_version }}
run: |
set -e
PKG=Test/SmokeTests/EmulatorSmokeTest/local-packages
[ -f "$PKG/Microsoft.Azure.DurableTask.Core.${CORE_VERSION}.nupkg" ] && echo "OK: Core ${CORE_VERSION}" || { echo "FAIL: Core missing"; ls -1 "$PKG"; exit 1; }
[ -f "$PKG/Microsoft.Azure.DurableTask.Emulator.${EMULATOR_VERSION}.nupkg" ] && echo "OK: Emulator ${EMULATOR_VERSION}" || { echo "FAIL: Emulator missing"; ls -1 "$PKG"; exit 1; }

- name: Generate NuGet.config
run: |
cat > Test/SmokeTests/EmulatorSmokeTest/NuGet.config << 'EOF'
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources><clear />
<add key="local-packages" value="./local-packages" />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
<packageSourceMapping>
<packageSource key="local-packages">
<package pattern="Microsoft.Azure.DurableTask.Core" />
<package pattern="Microsoft.Azure.DurableTask.Emulator" />
</packageSource>
<packageSource key="nuget.org"><package pattern="*" /></packageSource>
</packageSourceMapping>
</configuration>
EOF

- name: Build smoke test app
env:
CORE_VERSION: ${{ steps.version.outputs.core_version }}
EMULATOR_VERSION: ${{ steps.version.outputs.emulator_version }}
run: |
dotnet build Test/SmokeTests/EmulatorSmokeTest/EmulatorSmokeTest.csproj -c Release \
-p:SmokeTestCoreVersion=$CORE_VERSION \
-p:SmokeTestEmulatorVersion=$EMULATOR_VERSION

- name: Verify package versions in assets
env:
CORE_VERSION: ${{ steps.version.outputs.core_version }}
EMULATOR_VERSION: ${{ steps.version.outputs.emulator_version }}
run: |
set -e
ASSETS=Test/SmokeTests/EmulatorSmokeTest/obj/project.assets.json
grep -qi "Microsoft.Azure.DurableTask.Core/${CORE_VERSION}" "$ASSETS" && echo "OK: Core ${CORE_VERSION}" || { echo "FAIL: Core not found"; exit 1; }
grep -qi "Microsoft.Azure.DurableTask.Emulator/${EMULATOR_VERSION}" "$ASSETS" && echo "OK: Emulator ${EMULATOR_VERSION}" || { echo "FAIL: Emulator not found"; exit 1; }

Comment on lines +103 to +108
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

This check only verifies the package names appear in project.assets.json, which doesn’t prove they were restored from local-packages (or that the resolved version matches the locally packed one). A more reliable validation is to assert the resolved versions equal $DTFX_VERSION and that the package folder/path in project.assets.json points to the local feed (or that NuGet used source mapping as intended). This will make the pipeline’s failure mode clearer and prevent false positives.

Suggested change
run: |
set -e
echo "Verifying DTFx packages were restored from local-packages..."
ASSETS_FILE="Test/SmokeTests/EmulatorSmokeTest/obj/project.assets.json"
for pkg in "Microsoft.Azure.DurableTask.Core" "Microsoft.Azure.DurableTask.Emulator"; do
if grep -qi "$pkg" "$ASSETS_FILE"; then
echo " OK: $pkg found in project.assets.json"
else
echo " FAIL: $pkg NOT found"
exit 1
fi
done
echo "PASS: DTFx packages verified in build output."
env:
DTFX_VERSION: ${{ steps.version.outputs.dtfx_version }}
run: |
set -e
echo "Verifying DTFx packages were restored from local-packages..."
ASSETS_FILE="Test/SmokeTests/EmulatorSmokeTest/obj/project.assets.json"
LOCAL_PACKAGES="Test/SmokeTests/EmulatorSmokeTest/local-packages"
python3 - <<'PY'
import json
import os
import sys
assets_file = os.path.abspath("Test/SmokeTests/EmulatorSmokeTest/obj/project.assets.json")
local_packages = os.path.abspath("Test/SmokeTests/EmulatorSmokeTest/local-packages")
dtfx_version = os.environ["DTFX_VERSION"]
expected_packages = [
"Microsoft.Azure.DurableTask.Core",
"Microsoft.Azure.DurableTask.Emulator",
]
with open(assets_file, "r", encoding="utf-8") as f:
assets = json.load(f)
restore = assets.get("project", {}).get("restore", {})
sources = restore.get("sources", {})
normalized_sources = set()
for source in sources.keys():
normalized = source
if normalized.startswith("file://"):
normalized = normalized[7:]
normalized_sources.add(os.path.abspath(normalized))
if local_packages in normalized_sources:
print(f" OK: local restore source found: {local_packages}")
else:
print(f" FAIL: local restore source not found in project.assets.json: {local_packages}")
if normalized_sources:
print(" Restore sources found:")
for source in sorted(normalized_sources):
print(f" - {source}")
else:
print(" No restore sources found in project.assets.json")
sys.exit(1)
libraries = assets.get("libraries", {})
for package in expected_packages:
resolved_key = f"{package}/{dtfx_version}"
if resolved_key in libraries:
print(f" OK: {package} resolved to version {dtfx_version}")
else:
matching_versions = sorted(
key.split("/", 1)[1]
for key in libraries.keys()
if key.startswith(package + "/")
)
if matching_versions:
print(
f" FAIL: {package} did not resolve to version {dtfx_version}. "
f"Found: {', '.join(matching_versions)}"
)
else:
print(f" FAIL: {package} was not resolved in project.assets.json")
sys.exit(1)
print("PASS: DTFx packages verified from local source with expected versions.")
PY

Copilot uses AI. Check for mistakes.
- name: Run smoke test
env:
CORE_VERSION: ${{ steps.version.outputs.core_version }}
EMULATOR_VERSION: ${{ steps.version.outputs.emulator_version }}
run: |
dotnet run --project Test/SmokeTests/EmulatorSmokeTest/EmulatorSmokeTest.csproj \
-c Release --no-build \
-p:SmokeTestCoreVersion=$CORE_VERSION \
-p:SmokeTestEmulatorVersion=$EMULATOR_VERSION
2 changes: 2 additions & 0 deletions Test/SmokeTests/EmulatorSmokeTest/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
local-packages/
NuGet.config
28 changes: 28 additions & 0 deletions Test/SmokeTests/EmulatorSmokeTest/EmulatorSmokeTest.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>false</IsTestProject>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
<!-- Default versions for standalone restore (e.g. Dependency Submission workflow).
These are overridden by -p:SmokeTestCoreVersion / -p:SmokeTestEmulatorVersion in the CI pipeline. -->
<SmokeTestCoreVersion Condition="'$(SmokeTestCoreVersion)' == ''">3.7.1</SmokeTestCoreVersion>
<SmokeTestEmulatorVersion Condition="'$(SmokeTestEmulatorVersion)' == ''">2.6.0</SmokeTestEmulatorVersion>
</PropertyGroup>

<ItemGroup>
<!--
DTFx packages under test (from local-packages feed).
Core and Emulator use different version schemes, so they have separate properties.
Injected via -p:SmokeTestCoreVersion=... -p:SmokeTestEmulatorVersion=... at build time.
-->
<PackageReference Include="Microsoft.Azure.DurableTask.Core" Version="$(SmokeTestCoreVersion)" />
<PackageReference Include="Microsoft.Azure.DurableTask.Emulator" Version="$(SmokeTestEmulatorVersion)" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.1" />
</ItemGroup>

</Project>
111 changes: 111 additions & 0 deletions Test/SmokeTests/EmulatorSmokeTest/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Reflection;
using DurableTask.Core;
using DurableTask.Emulator;
using Microsoft.Extensions.Logging;

Console.WriteLine("=== DurableTask Emulator Smoke Test ===");
Console.WriteLine();

using ILoggerFactory loggerFactory = LoggerFactory.Create(builder =>
{
builder.AddConsole();
builder.SetMinimumLevel(LogLevel.Warning);
});

// Create the in-memory orchestration service
var orchestrationService = new LocalOrchestrationService();

// Start the worker with our orchestration and activities
var worker = new TaskHubWorker(orchestrationService, loggerFactory);
await worker
.AddTaskOrchestrations(typeof(HelloCitiesOrchestration))
.AddTaskActivities(typeof(SayHelloActivity))
.StartAsync();

// Create a client and start the orchestration
var client = new TaskHubClient(orchestrationService, loggerFactory: loggerFactory);
OrchestrationInstance instance = await client.CreateOrchestrationInstanceAsync(
typeof(HelloCitiesOrchestration), input: null);

Console.WriteLine($"Started orchestration: {instance.InstanceId}");

// Wait for completion
OrchestrationState result = await client.WaitForOrchestrationAsync(
instance, TimeSpan.FromSeconds(30), CancellationToken.None);

Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

WaitForOrchestrationAsync can return null on timeout in some DTFx versions/implementations; the current code will then throw a NullReferenceException when accessing result.OrchestrationStatus / result.Output. Handle the timeout case explicitly (e.g., check for null and treat it as a FAIL with a clear message) before dereferencing result.

Suggested change
if (result == null)
{
Console.WriteLine("FAIL: Orchestration did not complete within the timeout.");
await worker.StopAsync(true);
return 1;
}

Copilot uses AI. Check for mistakes.
Console.WriteLine($"Orchestration status: {result.OrchestrationStatus}");
Console.WriteLine($"Orchestration output: {result.Output}");

await worker.StopAsync(true);

Comment on lines +23 to +43
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

If anything throws between StartAsync() and StopAsync(true) (e.g., timeout/transport exceptions), the worker won’t be stopped, which can cause noisy teardown and occasionally hangs in CI depending on background threads/timers. Wrap the worker lifecycle in try/finally (or use an async disposal pattern if supported) to guarantee StopAsync(true) always runs.

Suggested change
await worker
.AddTaskOrchestrations(typeof(HelloCitiesOrchestration))
.AddTaskActivities(typeof(SayHelloActivity))
.StartAsync();
// Create a client and start the orchestration
var client = new TaskHubClient(orchestrationService, loggerFactory: loggerFactory);
OrchestrationInstance instance = await client.CreateOrchestrationInstanceAsync(
typeof(HelloCitiesOrchestration), input: null);
Console.WriteLine($"Started orchestration: {instance.InstanceId}");
// Wait for completion
OrchestrationState result = await client.WaitForOrchestrationAsync(
instance, TimeSpan.FromSeconds(30), CancellationToken.None);
Console.WriteLine($"Orchestration status: {result.OrchestrationStatus}");
Console.WriteLine($"Orchestration output: {result.Output}");
await worker.StopAsync(true);
worker
.AddTaskOrchestrations(typeof(HelloCitiesOrchestration))
.AddTaskActivities(typeof(SayHelloActivity));
await worker.StartAsync();
OrchestrationState result = null!;
try
{
// Create a client and start the orchestration
var client = new TaskHubClient(orchestrationService, loggerFactory: loggerFactory);
OrchestrationInstance instance = await client.CreateOrchestrationInstanceAsync(
typeof(HelloCitiesOrchestration), input: null);
Console.WriteLine($"Started orchestration: {instance.InstanceId}");
// Wait for completion
result = await client.WaitForOrchestrationAsync(
instance, TimeSpan.FromSeconds(30), CancellationToken.None);
Console.WriteLine($"Orchestration status: {result.OrchestrationStatus}");
Console.WriteLine($"Orchestration output: {result.Output}");
}
finally
{
await worker.StopAsync(true);
}

Copilot uses AI. Check for mistakes.
// Print loaded DTFx assembly versions (after usage to ensure all assemblies are loaded)
Console.WriteLine();
Console.WriteLine("Loaded DTFx assembly versions:");
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
string? name = asm.GetName().Name;
if (name != null && name.StartsWith("DurableTask.", StringComparison.OrdinalIgnoreCase))
{
string? infoVersion = asm
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion;
if (infoVersion != null && infoVersion.Contains('+'))
{
infoVersion = infoVersion[..infoVersion.IndexOf('+')];
}
Console.WriteLine($" {name} = {infoVersion ?? asm.GetName().Version?.ToString() ?? "unknown"}");
}
}

// Validate result
if (result.OrchestrationStatus != OrchestrationStatus.Completed)
{
Console.WriteLine("FAIL: Orchestration did not complete successfully.");
return 1;
}
Comment on lines +66 to +68
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

Environment.Exit(...) bypasses normal shutdown/cleanup, including disposing the loggerFactory declared with using and any other pending finalizers/flushes. Prefer setting Environment.ExitCode and returning from top-level statements (or throwing) so disposals and flushes run deterministically.

Copilot uses AI. Check for mistakes.

if (result.Output == null ||
!result.Output.Contains("Hello, Tokyo!") ||
!result.Output.Contains("Hello, London!") ||
!result.Output.Contains("Hello, Seattle!"))
{
Console.WriteLine("FAIL: Orchestration output did not contain expected greetings.");
return 1;
}
Comment on lines +75 to +77
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

Environment.Exit(...) bypasses normal shutdown/cleanup, including disposing the loggerFactory declared with using and any other pending finalizers/flushes. Prefer setting Environment.ExitCode and returning from top-level statements (or throwing) so disposals and flushes run deterministically.

Copilot uses AI. Check for mistakes.

Console.WriteLine();
Console.WriteLine("PASS: HelloCities orchestration completed with expected output.");
return 0;

// ---- Orchestration ----

/// <summary>
/// A simple function chaining orchestration that calls SayHello for three cities.
/// </summary>
public class HelloCitiesOrchestration : TaskOrchestration<string, object>
{
public override async Task<string> RunTask(OrchestrationContext context, object input)
{
string result = "";
result += await context.ScheduleTask<string>(typeof(SayHelloActivity), "Tokyo") + " ";
result += await context.ScheduleTask<string>(typeof(SayHelloActivity), "London") + " ";
result += await context.ScheduleTask<string>(typeof(SayHelloActivity), "Seattle");
return result;
}
}

// ---- Activity ----

/// <summary>
/// A simple activity that returns a greeting for the given city name.
/// </summary>
public class SayHelloActivity : TaskActivity<string, string>
{
protected override string Execute(TaskContext context, string cityName)
{
return $"Hello, {cityName}!";
}
}
Loading