Skip to content
Closed
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
338 changes: 338 additions & 0 deletions .github/workflows/dep-version-validation.yml
Original file line number Diff line number Diff line change
@@ -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 '<VersionPrefix>\K[^<]+' "$PROPS_FILE")
VERSION_SUFFIX=$(grep -oP '<VersionSuffix>\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|<VersionPrefix>${VERSION_PREFIX}</VersionPrefix>|<VersionPrefix>${MAJOR}.${MINOR}.${LOCAL_PATCH}</VersionPrefix>|" "$PROPS_FILE"

echo "Published version: ${VERSION_PREFIX}"
echo "Local version: ${LOCAL_VERSION}"
echo "sdk_version=${LOCAL_VERSION}" >> "$GITHUB_OUTPUT"
Comment on lines +62 to +83
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.

The version parsing/mutation is brittle: grep -P and the exact-string sed replacement will break if Release.props formatting changes (whitespace, attributes, multi-line XML), and mutating tracked files in-place makes failures harder to diagnose. Prefer deriving the version via MSBuild and passing it as properties (e.g., set VersionPrefix/VersionSuffix/Version via dotnet build/pack -p:...) rather than editing eng/targets/Release.props.

Copilot uses AI. Check for mistakes.

# ---- 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."
Comment on lines +159 to +173
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 does not actually verify the packages were restored from the local feed—it only verifies the package IDs appear in project.assets.json, which would also be true if they were restored from nuget.org. To make this a real guardrail, run restore explicitly with the intended NuGet.config (e.g., dotnet restore ... --configfile test/SmokeTests/DepValidation/NuGet.config) and/or rely on packageSourceMapping by making the restore fail if local-packages is missing (e.g., temporarily disabling nuget.org for the Microsoft.DurableTask.* pattern during this verification step).

Copilot uses AI. Check for mistakes.

# ---- 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
Comment on lines +190 to +198
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.

npm install -g azurite is unused (Azurite is started via the Docker image), adds time and introduces an extra external supply-chain dependency. Remove the npm install step and rely solely on the container.

Copilot uses AI. Check for mistakes.
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.

The Azurite image is unpinned (defaults to latest), which can introduce CI flakiness when the image updates. Pin to a specific tag (or ideally an image digest) to make the workflow deterministic.

Suggested change
mcr.microsoft.com/azure-storage/azurite
mcr.microsoft.com/azure-storage/azurite:3.33.0

Copilot uses AI. Check for mistakes.
# 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
1 change: 1 addition & 0 deletions test/SmokeTests/DepValidation/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
local-packages/
48 changes: 48 additions & 0 deletions test/SmokeTests/DepValidation/DepValidationApp.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
<OutputType>exe</OutputType>
<IsPackable>false</IsPackable>
<IsTestProject>false</IsTestProject>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
<!-- Disable SDK's source generation to allow reflection-based discovery of source-generated functions -->
<FunctionsEnableExecutorSourceGen>false</FunctionsEnableExecutorSourceGen>
<FunctionsEnableWorkerIndexing>false</FunctionsEnableWorkerIndexing>
</PropertyGroup>

<ItemGroup>
<!-- Azure Functions host packages (from nuget.org) -->
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.51.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http" Version="3.3.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.7" OutputItemType="Analyzer" />
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.

For the Functions Worker SDK analyzer reference, it’s typical to set PrivateAssets=\"all\" (and, if needed, IncludeAssets) to ensure it never flows to consumers via transitive dependencies. This project isn’t packable, but adding PrivateAssets=\"all\" makes the intent explicit and prevents accidental propagation if project settings change later.

Suggested change
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.7" OutputItemType="Analyzer" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.7" OutputItemType="Analyzer" PrivateAssets="all" />

Copilot uses AI. Check for mistakes.

<!-- Durable Functions Worker extension (from nuget.org) -->
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" Version="1.15.0" />

<!--
DurableTask SDK packages under test (from local-packages feed).
Version is injected via -p:SmokeTestSdkVersion=... at build time.
-->
<PackageReference Include="Microsoft.DurableTask.Abstractions" Version="$(SmokeTestSdkVersion)" />
<PackageReference Include="Microsoft.DurableTask.Client" Version="$(SmokeTestSdkVersion)" />
<PackageReference Include="Microsoft.DurableTask.Client.Grpc" Version="$(SmokeTestSdkVersion)" />
<PackageReference Include="Microsoft.DurableTask.Worker" Version="$(SmokeTestSdkVersion)" />
<PackageReference Include="Microsoft.DurableTask.Worker.Grpc" Version="$(SmokeTestSdkVersion)" />
<PackageReference Include="Microsoft.DurableTask.Grpc" Version="$(SmokeTestSdkVersion)" />
</ItemGroup>

<ItemGroup>
<None Update="host.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="local.settings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</None>
</ItemGroup>

</Project>
8 changes: 8 additions & 0 deletions test/SmokeTests/DepValidation/Dockerfile
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading