Add dependency version validation pipeline #1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # ================================================================================================ | |
| # 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" | |
| # ---- 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 |