From 7c8238a14b2e9a37609dc6b6b23bd428f2d41f58 Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 8 Apr 2026 12:09:36 -0700 Subject: [PATCH] Add dependency version validation pipeline Add a GitHub Actions workflow that validates dependency version bumps in Directory.Packages.props do not break the DurableTask SDK NuGet packages when consumed by Azure Functions. The pipeline: - Packs all SDK packages from source into a local NuGet feed - Builds a .NET isolated Function App using PackageReferences (not ProjectReferences) to the local packages - Starts Azurite and runs the Function App in Docker - Triggers a HelloCities orchestration and validates output - Verifies loaded SDK assembly versions match the locally built version This mirrors the SDK-PR-Validation pipeline pattern used in AAPT-DTMB. --- .github/workflows/dep-version-validation.yml | 338 ++++++++++++++++++ test/SmokeTests/DepValidation/.gitignore | 1 + .../DepValidation/DepValidationApp.csproj | 48 +++ test/SmokeTests/DepValidation/Dockerfile | 8 + .../DepValidation/HelloCitiesOrchestration.cs | 99 +++++ test/SmokeTests/DepValidation/NuGet.config | 23 ++ test/SmokeTests/DepValidation/Program.cs | 9 + test/SmokeTests/DepValidation/host.json | 11 + .../DepValidation/local.settings.json | 7 + 9 files changed, 544 insertions(+) create mode 100644 .github/workflows/dep-version-validation.yml create mode 100644 test/SmokeTests/DepValidation/.gitignore create mode 100644 test/SmokeTests/DepValidation/DepValidationApp.csproj create mode 100644 test/SmokeTests/DepValidation/Dockerfile create mode 100644 test/SmokeTests/DepValidation/HelloCitiesOrchestration.cs create mode 100644 test/SmokeTests/DepValidation/NuGet.config create mode 100644 test/SmokeTests/DepValidation/Program.cs create mode 100644 test/SmokeTests/DepValidation/host.json create mode 100644 test/SmokeTests/DepValidation/local.settings.json diff --git a/.github/workflows/dep-version-validation.yml b/.github/workflows/dep-version-validation.yml new file mode 100644 index 000000000..8d7000b40 --- /dev/null +++ b/.github/workflows/dep-version-validation.yml @@ -0,0 +1,338 @@ +# ================================================================================================ +# Dependency Version Validation Pipeline +# ================================================================================================ +# Validates that changes to dependency versions (Directory.Packages.props) do not break +# the DurableTask SDK NuGet packages at build or runtime when consumed by Azure Functions. +# +# This pipeline: +# 1. Packs all DurableTask SDK NuGet packages from source into a local feed +# 2. Builds an Azure Functions v4 (.NET isolated) app using those local packages +# 3. Starts Azurite (Azure Storage emulator) +# 4. Runs the Function App and triggers a HelloCities orchestration +# 5. Validates orchestration output and loaded SDK assembly versions +# ================================================================================================ + +name: Dependency Version Validation + +on: + push: + branches: + - main + - 'feature/**' + paths: + - 'Directory.Packages.props' + - 'Directory.Build.props' + - 'Directory.Build.targets' + - 'eng/targets/Release.props' + - 'test/SmokeTests/DepValidation/**' + pull_request: + paths: + - 'Directory.Packages.props' + - 'Directory.Build.props' + - 'Directory.Build.targets' + - 'eng/targets/Release.props' + - 'test/SmokeTests/DepValidation/**' + workflow_dispatch: + +permissions: + contents: read + +jobs: + dep-validation-smoke-test: + name: '.NET Isolated Smoke Test (NuGet Packages)' + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + # ---- Checkout & SDK Setup ---- + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup .NET from global.json + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Setup .NET 8.0 SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.0.x' + + # ---- Parse SDK Version ---- + - name: Parse SDK version + id: version + run: | + set -e + PROPS_FILE="eng/targets/Release.props" + VERSION_PREFIX=$(grep -oP '\K[^<]+' "$PROPS_FILE") + VERSION_SUFFIX=$(grep -oP '\K[^<]+' "$PROPS_FILE" || true) + + # Bump patch version to distinguish local build from published + IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION_PREFIX" + LOCAL_PATCH=$((PATCH + 1)) + LOCAL_VERSION="${MAJOR}.${MINOR}.${LOCAL_PATCH}" + if [ -n "$VERSION_SUFFIX" ]; then + LOCAL_VERSION="${LOCAL_VERSION}-${VERSION_SUFFIX}" + fi + + # Update the version in source so assembly versions are consistent + sed -i "s|${VERSION_PREFIX}|${MAJOR}.${MINOR}.${LOCAL_PATCH}|" "$PROPS_FILE" + + echo "Published version: ${VERSION_PREFIX}" + echo "Local version: ${LOCAL_VERSION}" + echo "sdk_version=${LOCAL_VERSION}" >> "$GITHUB_OUTPUT" + + # ---- Pack SDK Packages from Source ---- + - name: Pack SDK packages from source + run: | + set -e + LOCAL_PACKAGES="test/SmokeTests/DepValidation/local-packages" + mkdir -p "$LOCAL_PACKAGES" + + # Projects to pack (order matters for dependency resolution) + PROJECTS=( + "src/Abstractions/Abstractions.csproj" + "src/Client/Core/Client.csproj" + "src/Worker/Core/Worker.csproj" + "src/Grpc/Grpc.csproj" + "src/Client/Grpc/Client.Grpc.csproj" + "src/Worker/Grpc/Worker.Grpc.csproj" + ) + + # Build all projects first + for project in "${PROJECTS[@]}"; do + echo "Building $project..." + dotnet build "$project" -c Release + done + + # Pack from build output + for project in "${PROJECTS[@]}"; do + echo "Packing $project..." + dotnet pack "$project" -c Release --no-build --output "$LOCAL_PACKAGES" + done + + echo "Local packages:" + ls -la "$LOCAL_PACKAGES" + + # ---- Verify Packed Packages ---- + - name: Verify packed packages + env: + SDK_VERSION: ${{ steps.version.outputs.sdk_version }} + run: | + set -e + LOCAL_PACKAGES="test/SmokeTests/DepValidation/local-packages" + EXPECTED_PACKAGES=( + "Microsoft.DurableTask.Abstractions" + "Microsoft.DurableTask.Client" + "Microsoft.DurableTask.Client.Grpc" + "Microsoft.DurableTask.Worker" + "Microsoft.DurableTask.Worker.Grpc" + "Microsoft.DurableTask.Grpc" + ) + MISSING=0 + for pkg in "${EXPECTED_PACKAGES[@]}"; do + NUPKG="${pkg}.${SDK_VERSION}.nupkg" + if [ ! -f "$LOCAL_PACKAGES/$NUPKG" ]; then + echo "FAIL: Missing expected package: $NUPKG" + MISSING=$((MISSING + 1)) + else + echo " OK: $NUPKG" + fi + done + if [ $MISSING -gt 0 ]; then + echo "FAIL: $MISSING expected SDK package(s) missing for version $SDK_VERSION" + echo "Available packages:" + ls -1 "$LOCAL_PACKAGES" + exit 1 + fi + echo "PASS: All ${#EXPECTED_PACKAGES[@]} SDK packages verified for version $SDK_VERSION" + + # ---- Build Smoke Test App ---- + - name: Build smoke test app + env: + SDK_VERSION: ${{ steps.version.outputs.sdk_version }} + run: | + set -e + cd test/SmokeTests/DepValidation + dotnet build DepValidationApp.csproj -c Release -p:SmokeTestSdkVersion=$SDK_VERSION -v normal + + # ---- Verify SDK packages resolved from local-packages ---- + - name: Verify SDK packages from local source + run: | + set -e + echo "Verifying SDK packages were restored from local-packages..." + ASSETS_FILE="test/SmokeTests/DepValidation/obj/project.assets.json" + for pkg in "Microsoft.DurableTask.Abstractions" "Microsoft.DurableTask.Client.Grpc" "Microsoft.DurableTask.Worker.Grpc"; 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: SDK packages verified in build output." + + # ---- Publish Smoke Test App ---- + - name: Publish smoke test app + env: + SDK_VERSION: ${{ steps.version.outputs.sdk_version }} + run: | + set -e + cd test/SmokeTests/DepValidation + dotnet publish DepValidationApp.csproj -c Release -o ./publish -p:SmokeTestSdkVersion=$SDK_VERSION + + # ---- Build Docker Image ---- + - name: Build Docker image + run: | + docker build -t dep-validation-smoketest test/SmokeTests/DepValidation + + # ---- Start Azurite ---- + - name: Start Azurite + run: | + npm install -g azurite + docker network create smoketest-network 2>/dev/null || true + docker run -d \ + --name azurite-smoketest \ + --network smoketest-network \ + -p 10000:10000 -p 10001:10001 -p 10002:10002 \ + mcr.microsoft.com/azure-storage/azurite + # Wait for Azurite + for i in $(seq 1 30); do + if timeout 1 bash -c "echo > /dev/tcp/127.0.0.1/10000" 2>/dev/null; then + echo "Azurite is ready." + break + fi + if [ $i -eq 30 ]; then echo "FAIL: Azurite did not start"; exit 1; fi + sleep 1 + done + + # ---- Start Function App ---- + - name: Start Function App and run smoke test + env: + SDK_VERSION: ${{ steps.version.outputs.sdk_version }} + run: | + set -e + CONTAINER_NAME="dep-validation-smoketest-app" + TIMEOUT=120 + + docker run -d \ + --name $CONTAINER_NAME \ + --network smoketest-network \ + -p 8080:80 \ + -e AzureWebJobsStorage="DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://azurite-smoketest:10000/devstoreaccount1;QueueEndpoint=http://azurite-smoketest:10001/devstoreaccount1;TableEndpoint=http://azurite-smoketest:10002/devstoreaccount1;" \ + -e FUNCTIONS_WORKER_RUNTIME=dotnet-isolated \ + dep-validation-smoketest + + # Wait for the Function App to be ready + echo "Waiting for Function App to be ready..." + for i in $(seq 1 60); do + if curl -s -o /dev/null -w "%{http_code}" "http://localhost:8080/api/HelloCitiesOrchestration_HttpStart" 2>/dev/null | grep -qE "200|202|404"; then + echo "Function App is ready." + break + fi + if [ $i -eq 60 ]; then + echo "FAIL: Function App did not start within 120s" + docker logs $CONTAINER_NAME + exit 1 + fi + sleep 2 + done + + # Give it a few more seconds to fully initialize + sleep 5 + + # Trigger orchestration + echo "Triggering HelloCities orchestration..." + HTTP_CODE=$(curl -s -o /tmp/start-response.json -w "%{http_code}" -X POST "http://localhost:8080/api/HelloCitiesOrchestration_HttpStart") + RESPONSE=$(cat /tmp/start-response.json) + echo "HTTP status: $HTTP_CODE" + echo "Start response: $RESPONSE" + if [ "$HTTP_CODE" != "200" ] && [ "$HTTP_CODE" != "202" ]; then + echo "FAIL: Orchestration start returned HTTP $HTTP_CODE" + docker logs $CONTAINER_NAME + exit 1 + fi + STATUS_URL=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('statusQueryGetUri') or d.get('StatusQueryGetUri'))") + # Replace localhost references in status URL to work from host + STATUS_URL=$(echo "$STATUS_URL" | sed 's|localhost|localhost:8080|g' | sed 's|:80/|:8080/|g') + INSTANCE_ID=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('id') or d.get('Id'))") + echo "Instance ID: $INSTANCE_ID" + echo "Status URL: $STATUS_URL" + + # Poll for completion + ELAPSED=0 + while [ $ELAPSED -lt $TIMEOUT ]; do + sleep 2 + ELAPSED=$((ELAPSED + 2)) + STATUS_RESPONSE=$(curl -s "$STATUS_URL") + RUNTIME_STATUS=$(echo "$STATUS_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('runtimeStatus') or d.get('RuntimeStatus') or '')") + + if [ "$RUNTIME_STATUS" = "Completed" ]; then + OUTPUT=$(echo "$STATUS_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('output') or d.get('Output') or '')") + echo "Orchestration completed in ${ELAPSED}s. Output: $OUTPUT" + if echo "$OUTPUT" | grep -q "Hello, Tokyo!" && echo "$OUTPUT" | grep -q "Hello, London!" && echo "$OUTPUT" | grep -q "Hello, Seattle!"; then + echo "PASS: HelloCities orchestration returned expected output." + + # Verify loaded SDK assembly versions + echo "Verifying loaded SDK assembly versions..." + VERSION_JSON=$(curl -s "http://localhost:8080/api/SdkVersionCheck") + echo "Loaded SDK assembly versions: $VERSION_JSON" + VERSION_MISMATCH=0 + for asm_name in $(echo "$VERSION_JSON" | python3 -c "import sys,json; [print(k) for k in json.load(sys.stdin).keys()]"); do + asm_version=$(echo "$VERSION_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin)['$asm_name'])") + if [ "$asm_version" != "$SDK_VERSION" ]; then + echo "FAIL: Version mismatch for $asm_name: expected $SDK_VERSION, got $asm_version" + VERSION_MISMATCH=1 + else + echo " OK: $asm_name = $asm_version" + fi + done + if [ $VERSION_MISMATCH -ne 0 ]; then + echo "FAIL: SDK assembly version mismatch detected." + docker logs $CONTAINER_NAME + exit 1 + fi + echo "PASS: All loaded SDK assemblies match expected version $SDK_VERSION." + exit 0 + else + echo "FAIL: Output did not contain expected greetings." + docker logs $CONTAINER_NAME + exit 1 + fi + elif [ "$RUNTIME_STATUS" = "Failed" ] || [ "$RUNTIME_STATUS" = "Terminated" ]; then + echo "FAIL: Orchestration $RUNTIME_STATUS" + echo "$STATUS_RESPONSE" + docker logs $CONTAINER_NAME + exit 1 + fi + done + + echo "FAIL: Orchestration did not complete within ${TIMEOUT}s (last status: $RUNTIME_STATUS)" + docker logs $CONTAINER_NAME + exit 1 + + # ---- Collect Logs on Failure ---- + - name: Collect logs on failure + if: failure() + run: | + mkdir -p smoke-test-logs + docker logs dep-validation-smoketest-app > smoke-test-logs/function-app.log 2>&1 || true + docker logs azurite-smoketest > smoke-test-logs/azurite.log 2>&1 || true + + - name: Upload logs on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: dep-validation-smoke-test-logs + path: smoke-test-logs/ + if-no-files-found: ignore + + # ---- Cleanup ---- + - name: Cleanup + if: always() + run: | + docker stop dep-validation-smoketest-app 2>/dev/null || true + docker rm dep-validation-smoketest-app 2>/dev/null || true + docker stop azurite-smoketest 2>/dev/null || true + docker rm azurite-smoketest 2>/dev/null || true + docker network rm smoketest-network 2>/dev/null || true diff --git a/test/SmokeTests/DepValidation/.gitignore b/test/SmokeTests/DepValidation/.gitignore new file mode 100644 index 000000000..67d91e9d9 --- /dev/null +++ b/test/SmokeTests/DepValidation/.gitignore @@ -0,0 +1 @@ +local-packages/ diff --git a/test/SmokeTests/DepValidation/DepValidationApp.csproj b/test/SmokeTests/DepValidation/DepValidationApp.csproj new file mode 100644 index 000000000..e9dac7349 --- /dev/null +++ b/test/SmokeTests/DepValidation/DepValidationApp.csproj @@ -0,0 +1,48 @@ + + + + net8.0 + v4 + exe + false + false + enable + enable + false + + false + false + + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + Never + + + + diff --git a/test/SmokeTests/DepValidation/Dockerfile b/test/SmokeTests/DepValidation/Dockerfile new file mode 100644 index 000000000..deb72963c --- /dev/null +++ b/test/SmokeTests/DepValidation/Dockerfile @@ -0,0 +1,8 @@ +# Use the Azure Functions base image for .NET 8.0 isolated +FROM mcr.microsoft.com/azure-functions/dotnet-isolated:4-dotnet-isolated8.0 + +ENV AzureWebJobsScriptRoot=/home/site/wwwroot \ + AzureFunctionsJobHost__Logging__Console__IsEnabled=true \ + FUNCTIONS_WORKER_RUNTIME=dotnet-isolated + +COPY ./publish /home/site/wwwroot diff --git a/test/SmokeTests/DepValidation/HelloCitiesOrchestration.cs b/test/SmokeTests/DepValidation/HelloCitiesOrchestration.cs new file mode 100644 index 000000000..fdd766ffe --- /dev/null +++ b/test/SmokeTests/DepValidation/HelloCitiesOrchestration.cs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Reflection; +using System.Text.Json; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.Extensions.Logging; + +namespace DepValidationApp; + +/// +/// A simple function chaining orchestration used to smoke-test that the +/// locally-packed DurableTask SDK NuGet packages work correctly at runtime +/// with the Azure Functions Worker extension. +/// +public static class HelloCitiesOrchestration +{ + /// + /// Returns the versions of the loaded DurableTask SDK assemblies so + /// smoke tests can verify the correct package versions are being used. + /// + [Function("SdkVersionCheck")] + public static HttpResponseData SdkVersionCheck( + [HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequestData req) + { + string[] sdkAssemblyPrefixes = new[] + { + "Microsoft.DurableTask.Abstractions", + "Microsoft.DurableTask.Client", + "Microsoft.DurableTask.Client.Grpc", + "Microsoft.DurableTask.Worker", + "Microsoft.DurableTask.Worker.Grpc", + "Microsoft.DurableTask.Grpc", + }; + + SortedDictionary loadedVersions = new(); + foreach (Assembly asm in AppDomain.CurrentDomain.GetAssemblies()) + { + AssemblyName name = asm.GetName(); + foreach (string prefix in sdkAssemblyPrefixes) + { + if (string.Equals(name.Name, prefix, StringComparison.OrdinalIgnoreCase)) + { + string? infoVersion = asm + .GetCustomAttribute() + ?.InformationalVersion; + + // Strip source-link commit hash suffix (e.g. "1.23.3+abc123") + if (infoVersion != null && infoVersion.Contains('+')) + { + infoVersion = infoVersion[..infoVersion.IndexOf('+')]; + } + + loadedVersions[name.Name!] = infoVersion ?? name.Version?.ToString() ?? "unknown"; + } + } + } + + HttpResponseData response = req.CreateResponse(HttpStatusCode.OK); + response.Headers.Add("Content-Type", "application/json"); + response.WriteString(JsonSerializer.Serialize(loadedVersions)); + return response; + } + + [Function(nameof(HelloCitiesOrchestration))] + public static async Task RunOrchestrator( + [OrchestrationTrigger] TaskOrchestrationContext context) + { + string result = ""; + result += await context.CallActivityAsync(nameof(SayHello), "Tokyo") + " "; + result += await context.CallActivityAsync(nameof(SayHello), "London") + " "; + result += await context.CallActivityAsync(nameof(SayHello), "Seattle"); + return result; + } + + [Function(nameof(SayHello))] + public static string SayHello([ActivityTrigger] string cityName, FunctionContext executionContext) + { + ILogger logger = executionContext.GetLogger(nameof(SayHello)); + logger.LogInformation("Saying hello to {CityName}!", cityName); + return $"Hello, {cityName}!"; + } + + [Function("HelloCitiesOrchestration_HttpStart")] + public static async Task HttpStart( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post")] HttpRequestData req, + [DurableClient] DurableTaskClient client, + FunctionContext executionContext) + { + ILogger logger = executionContext.GetLogger("HelloCitiesOrchestration_HttpStart"); + string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(nameof(HelloCitiesOrchestration)); + logger.LogInformation("Started orchestration with ID = '{InstanceId}'.", instanceId); + return client.CreateCheckStatusResponse(req, instanceId); + } +} diff --git a/test/SmokeTests/DepValidation/NuGet.config b/test/SmokeTests/DepValidation/NuGet.config new file mode 100644 index 000000000..8d5704ada --- /dev/null +++ b/test/SmokeTests/DepValidation/NuGet.config @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + diff --git a/test/SmokeTests/DepValidation/Program.cs b/test/SmokeTests/DepValidation/Program.cs new file mode 100644 index 000000000..c1a7c7723 --- /dev/null +++ b/test/SmokeTests/DepValidation/Program.cs @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Hosting; + +new HostBuilder() + .ConfigureFunctionsWorkerDefaults() + .Build() + .Run(); diff --git a/test/SmokeTests/DepValidation/host.json b/test/SmokeTests/DepValidation/host.json new file mode 100644 index 000000000..369b5be84 --- /dev/null +++ b/test/SmokeTests/DepValidation/host.json @@ -0,0 +1,11 @@ +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + } + } + } +} diff --git a/test/SmokeTests/DepValidation/local.settings.json b/test/SmokeTests/DepValidation/local.settings.json new file mode 100644 index 000000000..8eea88f48 --- /dev/null +++ b/test/SmokeTests/DepValidation/local.settings.json @@ -0,0 +1,7 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated" + } +}