Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
168 changes: 168 additions & 0 deletions .github/workflows/dep-version-validation.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
# ================================================================================================
# Dependency Version Validation Pipeline
# ================================================================================================
# Validates that changes to dependency versions (Directory.Packages.props) do not break
# the DurableTask Framework (DTFx) NuGet packages at build or runtime.
#
# The DTFx Core and Emulator packages are consumed downstream by:
# - azure-functions-durable-extension (WebJobs extension)
# - AAPT-DTMB (DTS data plane)
#
# This pipeline:
# 1. Packs DurableTask.Core and DurableTask.Emulator from source into a local feed
# 2. Builds a standalone console app that runs a HelloCities orchestration
# using the Emulator backend
# 3. Validates orchestration output and loaded assembly versions
# ================================================================================================

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:
# ---- Checkout & SDK Setup ----
- name: Checkout code
uses: actions/checkout@v4

- name: Setup .NET 8.0 SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'

# ---- Parse DTFx Version ----
- name: Parse DTFx Core version
id: version
run: |
set -e
CSPROJ_FILE="src/DurableTask.Core/DurableTask.Core.csproj"
MAJOR=$(grep -oP '<MajorVersion>\K[^<]+' "$CSPROJ_FILE")
MINOR=$(grep -oP '<MinorVersion>\K[^<]+' "$CSPROJ_FILE")
PATCH=$(grep -oP '<PatchVersion>\K[^<]+' "$CSPROJ_FILE")

# Bump patch version to distinguish local build from published
LOCAL_PATCH=$((PATCH + 1))
LOCAL_VERSION="${MAJOR}.${MINOR}.${LOCAL_PATCH}"

# Update version in Core csproj
sed -i "s|<PatchVersion>${PATCH}</PatchVersion>|<PatchVersion>${LOCAL_PATCH}</PatchVersion>|" "$CSPROJ_FILE"

echo "Published version: ${MAJOR}.${MINOR}.${PATCH}"
echo "Local version: ${LOCAL_VERSION}"
echo "dtfx_version=${LOCAL_VERSION}" >> "$GITHUB_OUTPUT"

# ---- Pack DTFx Packages from Source ----
- name: Pack DTFx packages from source
run: |
set -e
LOCAL_PACKAGES="Test/SmokeTests/EmulatorSmokeTest/local-packages"
mkdir -p "$LOCAL_PACKAGES"

# Build Core first (Emulator depends on it)
echo "Building DurableTask.Core..."
dotnet build src/DurableTask.Core/DurableTask.Core.csproj -c Release

echo "Building DurableTask.Emulator..."
dotnet build src/DurableTask.Emulator/DurableTask.Emulator.csproj -c Release

# Pack from build output
echo "Packing DurableTask.Core..."
dotnet pack src/DurableTask.Core/DurableTask.Core.csproj -c Release --no-build --output "$LOCAL_PACKAGES"

echo "Packing DurableTask.Emulator..."
dotnet pack src/DurableTask.Emulator/DurableTask.Emulator.csproj -c Release --no-build --output "$LOCAL_PACKAGES"

echo "Local packages:"
ls -la "$LOCAL_PACKAGES"

# ---- Verify Packed Packages ----
- name: Verify packed packages
env:
DTFX_VERSION: ${{ steps.version.outputs.dtfx_version }}
run: |
set -e
LOCAL_PACKAGES="Test/SmokeTests/EmulatorSmokeTest/local-packages"

CORE_PKG="Microsoft.Azure.DurableTask.Core.${DTFX_VERSION}.nupkg"
if [ ! -f "$LOCAL_PACKAGES/$CORE_PKG" ]; then
echo "FAIL: Missing Core package: $CORE_PKG"
echo "Available packages:"
ls -1 "$LOCAL_PACKAGES"
exit 1
fi
echo " OK: $CORE_PKG"

# Emulator may use a different version scheme - check that at least one Emulator package exists
EMULATOR_FOUND=0
for f in "$LOCAL_PACKAGES"/Microsoft.Azure.DurableTask.Emulator.*.nupkg; do
if [ -f "$f" ]; then
echo " OK: $(basename $f)"
EMULATOR_FOUND=1
fi
done
if [ $EMULATOR_FOUND -eq 0 ]; then
echo "FAIL: No Emulator package found"
ls -1 "$LOCAL_PACKAGES"
exit 1
fi

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

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

The workflow allows the Emulator package to have a different version (it only checks that some Microsoft.Azure.DurableTask.Emulator.*.nupkg exists), but the smoke test project pins Emulator to $(SmokeTestDtfxVersion) (same as Core). If Emulator’s packed version doesn’t match DTFX_VERSION, restore/build will fail. Fix by ensuring Emulator is packed with the same version you pass into SmokeTestDtfxVersion (e.g., bump/version both consistently), or by passing separate version properties for Core vs Emulator and wiring them in the csproj.

Suggested change
# Emulator may use a different version scheme - check that at least one Emulator package exists
EMULATOR_FOUND=0
for f in "$LOCAL_PACKAGES"/Microsoft.Azure.DurableTask.Emulator.*.nupkg; do
if [ -f "$f" ]; then
echo " OK: $(basename $f)"
EMULATOR_FOUND=1
fi
done
if [ $EMULATOR_FOUND -eq 0 ]; then
echo "FAIL: No Emulator package found"
ls -1 "$LOCAL_PACKAGES"
exit 1
fi
EMULATOR_PKG="Microsoft.Azure.DurableTask.Emulator.${DTFX_VERSION}.nupkg"
if [ ! -f "$LOCAL_PACKAGES/$EMULATOR_PKG" ]; then
echo "FAIL: Missing Emulator package: $EMULATOR_PKG"
echo "Available packages:"
ls -1 "$LOCAL_PACKAGES"
exit 1
fi
echo " OK: $EMULATOR_PKG"

Copilot uses AI. Check for mistakes.
echo "PASS: DTFx packages verified."

# ---- Build Smoke Test App ----
- name: Build smoke test app
env:
DTFX_VERSION: ${{ steps.version.outputs.dtfx_version }}
run: |
set -e
cd Test/SmokeTests/EmulatorSmokeTest
dotnet build EmulatorSmokeTest.csproj -c Release -p:SmokeTestDtfxVersion=$DTFX_VERSION -v normal

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

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

The workflow allows the Emulator package to have a different version (it only checks that some Microsoft.Azure.DurableTask.Emulator.*.nupkg exists), but the smoke test project pins Emulator to $(SmokeTestDtfxVersion) (same as Core). If Emulator’s packed version doesn’t match DTFX_VERSION, restore/build will fail. Fix by ensuring Emulator is packed with the same version you pass into SmokeTestDtfxVersion (e.g., bump/version both consistently), or by passing separate version properties for Core vs Emulator and wiring them in the csproj.

Copilot uses AI. Check for mistakes.

# ---- Verify DTFx packages resolved from local-packages ----
- name: Verify DTFx packages from local source
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."

Comment on lines +103 to +108

Copilot AI Apr 8, 2026

Copy link

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.
# ---- Run Smoke Test ----
- name: Run smoke test
env:
DTFX_VERSION: ${{ steps.version.outputs.dtfx_version }}
run: |
set -e
echo "Running Emulator Smoke Test..."
dotnet run --project Test/SmokeTests/EmulatorSmokeTest/EmulatorSmokeTest.csproj \
-c Release \
--no-build \
-p:SmokeTestDtfxVersion=$DTFX_VERSION

echo "Smoke test completed successfully."
1 change: 1 addition & 0 deletions Test/SmokeTests/EmulatorSmokeTest/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
local-packages/
23 changes: 23 additions & 0 deletions Test/SmokeTests/EmulatorSmokeTest/EmulatorSmokeTest.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<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>
</PropertyGroup>

<ItemGroup>
<!--
DTFx packages under test (from local-packages feed).
Version is injected via -p:SmokeTestDtfxVersion=... at build time.
-->
<PackageReference Include="Microsoft.Azure.DurableTask.Core" Version="$(SmokeTestDtfxVersion)" />
<PackageReference Include="Microsoft.Azure.DurableTask.Emulator" Version="$(SmokeTestDtfxVersion)" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.1" />
</ItemGroup>

</Project>
22 changes: 22 additions & 0 deletions Test/SmokeTests/EmulatorSmokeTest/NuGet.config
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?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>

<!--
Package source mapping ensures that the DTFx packages under test are ONLY
resolved from the local-packages feed.
-->
<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>
110 changes: 110 additions & 0 deletions Test/SmokeTests/EmulatorSmokeTest/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

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);
});

// Print loaded DTFx assembly versions
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<System.Reflection.AssemblyInformationalVersionAttribute>()
?.InformationalVersion;

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

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

asm.GetCustomAttribute<T>() is an extension method from System.Reflection (CustomAttributeExtensions). With implicit usings, System.Reflection typically isn’t imported, so this may fail to compile. Fix by adding using System.Reflection; at the top, or by calling the extension method with a fully-qualified name.

Copilot uses AI. Check for mistakes.
if (infoVersion != null && infoVersion.Contains('+'))
{
infoVersion = infoVersion[..infoVersion.IndexOf('+')];
}
Console.WriteLine($" {name} = {infoVersion ?? asm.GetName().Version?.ToString() ?? "unknown"}");
}
}

Copilot AI Apr 8, 2026

Copy link

Choose a reason for hiding this comment

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

This prints the currently loaded assemblies before the code first touches any DTFx types (the first concrete usage is later at new LocalOrchestrationService() / new TaskHubWorker(...)). On .NET, referenced assemblies are often loaded lazily, so this can end up printing nothing (or an incomplete list), reducing the usefulness of the validation output. Consider moving this block to after the worker/client are created (or after the orchestration run), or explicitly forcing load via known types (e.g., typeof(TaskHubClient).Assembly) before enumerating.

Copilot uses AI. Check for mistakes.
Console.WriteLine();

// 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);

Copilot AI Apr 8, 2026

Copy link

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

Copilot AI Apr 8, 2026

Copy link

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.
// Validate result
if (result.OrchestrationStatus != OrchestrationStatus.Completed)
{
Console.WriteLine("FAIL: Orchestration did not complete successfully.");
Environment.Exit(1);
}
Comment on lines +66 to +68

Copilot AI Apr 8, 2026

Copy link

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.");
Environment.Exit(1);
}
Comment on lines +75 to +77

Copilot AI Apr 8, 2026

Copy link

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.");
Environment.Exit(0);

Copilot AI Apr 8, 2026

Copy link

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.

// ---- 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