From 0bcb11fbb4512677fd7d380774e150cae117df6d Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Tue, 3 Jun 2025 11:20:50 +0100 Subject: [PATCH] Add build and test for dotnet projects (#51) * Add build and test in subfolder to see if it works * Move build and test file to root workflows * Update to .net 9, add checkout filter and fix package check * Only run framework tests on windows-latest * Fix if statements and restrict package check to release builds * Fix coverage check * Add dotnet format * Address PR comment Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Address PR comments Co-authored-by: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> * Address PR comments Co-authored-by: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> * Fix error * update package install check to run in temp folder. * Fix typo in package install check * Update net472 handling for tests * Fix dotnet version name * Update package install check with framework from matrix * Remove switch from pack and rename to targetFramework * Try /p switch for pack * Move /p switch * rename /p to /property * Restrict package install check to netx --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> --- .github/workflows/dotnet-build-and-test.yml | 221 ++++++++++++++++++++ .github/workflows/dotnet-check-coverage.ps1 | 71 +++++++ .github/workflows/dotnet-format.yml | 95 +++++++++ 3 files changed, 387 insertions(+) create mode 100644 .github/workflows/dotnet-build-and-test.yml create mode 100644 .github/workflows/dotnet-check-coverage.ps1 create mode 100644 .github/workflows/dotnet-format.yml diff --git a/.github/workflows/dotnet-build-and-test.yml b/.github/workflows/dotnet-build-and-test.yml new file mode 100644 index 0000000000..435c623466 --- /dev/null +++ b/.github/workflows/dotnet-build-and-test.yml @@ -0,0 +1,221 @@ +# +# This workflow will build all .slnx files in the dotnet folder, and run all unit tests and integration tests using dotnet docker containers, +# each targeting a single version of the dotnet SDK. +# + +name: dotnet-build-and-test + +on: + workflow_dispatch: + pull_request: + branches: ["main", "feature*"] + paths: + - dotnet/** + - '.github/workflows/dotnet-build-and-test.yml' + merge_group: + branches: ["main"] + +env: + COVERAGE_THRESHOLD: 80 + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + id-token: "write" + +jobs: + dotnet-build-and-test: + strategy: + fail-fast: false + matrix: + include: + - { targetFramework: "net9.0", os: "ubuntu-latest", configuration: Release, integration-tests: true, environment: "integration" } + - { targetFramework: "net9.0", os: "ubuntu-latest", configuration: Debug } + - { targetFramework: "net9.0", os: "windows-latest", configuration: Release } + - { targetFramework: "net472", os: "windows-latest", configuration: Release, integration-tests: true, environment: "integration" } + + runs-on: ${{ matrix.os }} + environment: ${{ matrix.environment }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + sparse-checkout: | + . + .github + dotnet + - name: Setup dotnet + uses: actions/setup-dotnet@v4.3.1 + with: + global-json-file: ${{ github.workspace }}/dotnet/global.json + - name: Build dotnet solutions + shell: bash + run: | + export SOLUTIONS=$(find ./dotnet/ -type f -name "*.slnx" | tr '\n' ' ') + for solution in $SOLUTIONS; do + dotnet build $solution -f ${{ matrix.targetFramework }} -c ${{ matrix.configuration }} --warnaserror + done + - name: Package install check + shell: bash + # All frameworks are only built for the release configuration, so we only run this step for the release configuration + # and dotnet new doesn't support net472 + if: matrix.configuration == 'Release' && matrix.targetFramework != 'net472' + run: | + TEMP_DIR=$(mktemp -d) + + export SOLUTIONS=$(find ./dotnet/ -type f -name "*.slnx" | tr '\n' ' ') + for solution in $SOLUTIONS; do + dotnet pack $solution /property:TargetFrameworks=${{ matrix.targetFramework }} -c ${{ matrix.configuration }} --no-build --no-restore --output "$TEMP_DIR/artifacts" + done + + cd "$TEMP_DIR" + + # Create a new console app to test the package installation + dotnet new console -f ${{ matrix.targetFramework }} --name packcheck --output consoleapp + + # Create minimal nuget.config and use only dotnet nuget commands + echo '' > consoleapp/nuget.config + + # Add sources with local first using dotnet nuget commands + dotnet nuget add source ../artifacts --name local --configfile consoleapp/nuget.config + dotnet nuget add source https://api.nuget.org/v3/index.json --name nuget.org --configfile consoleapp/nuget.config + + # Change to project directory to ensure local nuget.config is used + cd consoleapp + dotnet add packcheck.csproj package Microsoft.Agents --prerelease + dotnet build -f ${{ matrix.targetFramework }} -c ${{ matrix.configuration }} packcheck.csproj + + # Clean up + cd .. + cd .. + rm -rf "$TEMP_DIR" + + - name: Run Unit Tests Windows + shell: bash + run: | + export UT_PROJECTS=$(find ./dotnet -type f -name "*.UnitTests.csproj" | tr '\n' ' ') + for project in $UT_PROJECTS; do + dotnet test -f ${{ matrix.targetFramework }} -c ${{ matrix.configuration }} $project --no-build -v Normal --logger trx --collect:"XPlat Code Coverage" --results-directory:"TestResults/Coverage/" -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.ExcludeByAttribute=GeneratedCodeAttribute,CompilerGeneratedAttribute,ExcludeFromCodeCoverageAttribute + done + + - name: Azure CLI Login + if: github.event_name != 'pull_request' && matrix.integration-tests + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Run Integration Tests + shell: bash + if: github.event_name != 'pull_request' && matrix.integration-tests + run: | + export INTEGRATION_TEST_PROJECTS=$(find ./dotnet -type f -name "*IntegrationTests.csproj" | grep -v "Experimental.Orchestration.Flow.IntegrationTests.csproj" | grep -v "VectorDataIntegrationTests.csproj" | tr '\n' ' ') + for project in $INTEGRATION_TEST_PROJECTS; do + dotnet test -f net8.0 -c ${{ matrix.configuration }} $project --no-build -v Normal --logger trx + done + env: + # Azure OpenAI Deployments + AzureOpenAI__Endpoint: ${{ secrets.AZUREOPENAI__ENDPOINT }} + AzureOpenAI__DeploymentName: ${{ vars.AZUREOPENAI__DEPLOYMENTNAME }} + AzureOpenAI__ChatDeploymentName: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} + AzureOpenAIEmbeddings__Endpoint: ${{ secrets.AZUREOPENAI__ENDPOINT }} + AzureOpenAIEmbeddings__DeploymentName: ${{ vars.AZUREOPENAIEMBEDDING__DEPLOYMENTNAME }} + AzureOpenAITextToAudio__Endpoint: ${{ secrets.AZUREOPENAITEXTTOAUDIO__ENDPOINT }} + AzureOpenAITextToAudio__DeploymentName: ${{ vars.AZUREOPENAITEXTTOAUDIO__DEPLOYMENTNAME }} + AzureOpenAIAudioToText__Endpoint: ${{ secrets.AZUREOPENAIAUDIOTOTEXT__ENDPOINT }} + AzureOpenAIAudioToText__DeploymentName: ${{ vars.AZUREOPENAIAUDIOTOTEXT__DEPLOYMENTNAME }} + AzureOpenAITextToImage__Endpoint: ${{ secrets.AZUREOPENAITEXTTOIMAGE__ENDPOINT }} + AzureOpenAITextToImage__DeploymentName: ${{ vars.AZUREOPENAITEXTTOIMAGE__DEPLOYMENTNAME }} + Planners__AzureOpenAI__Endpoint: ${{ secrets.PLANNERS__AZUREOPENAI__ENDPOINT }} + Planners__AzureOpenAI__DeploymentName: ${{ vars.PLANNERS__AZUREOPENAI__DEPLOYMENTNAME }} + # OpenAI Models + OpenAI__ApiKey: ${{ secrets.OPENAI__APIKEY }} + OpenAI__ChatModelId: ${{ vars.OPENAI__CHATMODELID }} + OpenAIEmbeddings__ApiKey: ${{ secrets.OPENAIEMBEDDINGS__APIKEY }} + OpenAIEmbeddings__ModelId: ${{ vars.OPENAIEMBEDDINGS__MODELID }} + OpenAITextToAudio__ApiKey: ${{ secrets.OPENAITEXTTOAUDIO__APIKEY }} + OpenAITextToAudio__ModelId: ${{ vars.OPENAITEXTTOAUDIO__MODELID }} + OpenAIAudioToText__ApiKey: ${{ secrets.OPENAIAUDIOTOTEXT__APIKEY }} + OpenAIAudioToText__ModelId: ${{ vars.OPENAIAUDIOTOTEXT__MODELID }} + OpenAITextToImage__ApiKey: ${{ secrets.OPENAITEXTTOIMAGE__APIKEY }} + OpenAITextToImage__ModelId: ${{ vars.OPENAITEXTTOIMAGE__MODELID }} + Planners__OpenAI__ApiKey: ${{ secrets.PLANNERS__OPENAI__APIKEY }} + Planners__OpenAI__ModelId: ${{ vars.PLANNERS__OPENAI__MODELID }} + # Bing Web Search + Bing__ApiKey: ${{ secrets.BING__APIKEY }} + # Google Web Search + Google__SearchEngineId: ${{ secrets.GOOGLE__SEARCHENGINEID }} + Google__ApiKey: ${{ secrets.GOOGLE__APIKEY }} + # Azure AI Inference Endpoint + AzureAIInference__ApiKey: ${{ secrets.AZUREAIINFERENCE__APIKEY }} + AzureAIInference__Endpoint: ${{ secrets.AZUREAIINFERENCE__ENDPOINT }} + AzureAIInference__ChatModelId: ${{ vars.AZUREAIINFERENCE__CHATMODELID }} + # Azure AI Foundry + AzureAI__Endpoint: ${{ secrets.AZUREAI__ENDPOINT }} + AzureAI__ConnectionString: ${{ secrets.AZUREAI__CONNECTIONSTRING }} + AzureAI__ChatModelId: ${{ vars.AZUREAI__CHATMODELID }} + + # Generate test reports and check coverage + - name: Generate test reports + uses: danielpalme/ReportGenerator-GitHub-Action@5.4.4 + with: + reports: "./TestResults/Coverage/**/coverage.cobertura.xml" + targetdir: "./TestResults/Reports" + reporttypes: "HtmlInline;JsonSummary" + + - name: Upload coverage report artifact + uses: actions/upload-artifact@v4 + with: + name: CoverageReport-${{ matrix.os }}-${{ matrix.targetFramework }}-${{ matrix.configuration }} # Artifact name + path: ./TestResults/Reports # Directory containing files to upload + + - name: Check coverage + shell: pwsh + run: .github/workflows/dotnet-check-coverage.ps1 -JsonReportPath "TestResults/Reports/Summary.json" -CoverageThreshold $env:COVERAGE_THRESHOLD + + # This final job is required to satisfy the merge queue. It must only run (or succeed) if no tests failed + dotnet-build-and-test-check: + if: always() + runs-on: ubuntu-latest + needs: [dotnet-build-and-test] + steps: + - name: Get Date + shell: bash + run: | + echo "date=$(date +'%m/%d/%Y %H:%M:%S')" >> "$GITHUB_ENV" + + - name: Run Type is Daily + if: ${{ github.event_name == 'schedule' }} + shell: bash + run: | + echo "run_type=Daily" >> "$GITHUB_ENV" + + - name: Run Type is Manual + if: ${{ github.event_name == 'workflow_dispatch' }} + shell: bash + run: | + echo "run_type=Manual" >> "$GITHUB_ENV" + + - name: Run Type is ${{ github.event_name }} + if: ${{ github.event_name != 'schedule' && github.event_name != 'workflow_dispatch'}} + shell: bash + run: | + echo "run_type=${{ github.event_name }}" >> "$GITHUB_ENV" + + - name: Fail workflow if tests failed + id: check_tests_failed + if: contains(join(needs.*.result, ','), 'failure') + uses: actions/github-script@v6 + with: + script: core.setFailed('Integration Tests Failed!') + + - name: Fail workflow if tests cancelled + id: check_tests_cancelled + if: contains(join(needs.*.result, ','), 'cancelled') + uses: actions/github-script@v6 + with: + script: core.setFailed('Integration Tests Cancelled!') diff --git a/.github/workflows/dotnet-check-coverage.ps1 b/.github/workflows/dotnet-check-coverage.ps1 new file mode 100644 index 0000000000..971da675ae --- /dev/null +++ b/.github/workflows/dotnet-check-coverage.ps1 @@ -0,0 +1,71 @@ +param ( + [string]$JsonReportPath, + [double]$CoverageThreshold +) + +$jsonContent = Get-Content $JsonReportPath -Raw | ConvertFrom-Json +$coverageBelowThreshold = $false + +$nonExperimentalAssemblies = [System.Collections.Generic.HashSet[string]]::new() + +$assembliesCollection = @( + 'Microsoft.Agents.Abstractions' + 'Microsoft.Agents' +) + +foreach ($assembly in $assembliesCollection) { + $nonExperimentalAssemblies.Add($assembly) +} + +function Get-FormattedValue { + param ( + [float]$Coverage, + [bool]$UseIcon = $false + ) + $formattedNumber = "{0:N1}" -f $Coverage + $icon = if (-not $UseIcon) { "" } elseif ($Coverage -ge $CoverageThreshold) { '✅' } else { '❌' } + + return "$formattedNumber% $icon" +} + +$lineCoverage = $jsonContent.summary.linecoverage +$branchCoverage = $jsonContent.summary.branchcoverage + +$totalTableData = [PSCustomObject]@{ + 'Metric' = 'Total Coverage' + 'Line Coverage' = Get-FormattedValue -Coverage $lineCoverage + 'Branch Coverage' = Get-FormattedValue -Coverage $branchCoverage +} + +$totalTableData | Format-Table -AutoSize + +$assemblyTableData = @() + +foreach ($assembly in $jsonContent.coverage.assemblies) { + $assemblyName = $assembly.name + $assemblyLineCoverage = $assembly.coverage + $assemblyBranchCoverage = $assembly.branchcoverage + + $isNonExperimentalAssembly = $nonExperimentalAssemblies -contains $assemblyName + + if ($isNonExperimentalAssembly -and ($assemblyLineCoverage -lt $CoverageThreshold -or $assemblyBranchCoverage -lt $CoverageThreshold)) { + $coverageBelowThreshold = $true + } + + $assemblyTableData += [PSCustomObject]@{ + 'Assembly Name' = $assemblyName + 'Line' = Get-FormattedValue -Coverage $assemblyLineCoverage -UseIcon $isNonExperimentalAssembly + 'Branch' = Get-FormattedValue -Coverage $assemblyBranchCoverage -UseIcon $isNonExperimentalAssembly + } +} + +$sortedTable = $assemblyTableData | Sort-Object { + $nonExperimentalAssemblies -contains $_.'Assembly Name' +} -Descending + +$sortedTable | Format-Table -AutoSize + +if ($coverageBelowThreshold) { + Write-Host "Code coverage is lower than defined threshold: $CoverageThreshold. Stopping the task." + exit 1 +} diff --git a/.github/workflows/dotnet-format.yml b/.github/workflows/dotnet-format.yml new file mode 100644 index 0000000000..893bd969b8 --- /dev/null +++ b/.github/workflows/dotnet-format.yml @@ -0,0 +1,95 @@ +# +# This workflow runs the dotnet formatter on all c-sharp code. +# + +name: dotnet-format + +on: + workflow_dispatch: + pull_request: + branches: ["main", "feature*"] + paths: + - dotnet/** + - '.github/workflows/dotnet-format.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + check-format: + strategy: + fail-fast: false + matrix: + include: + - { dotnet: "9.0", configuration: Release, os: ubuntu-latest } + + runs-on: ${{ matrix.os }} + env: + NUGET_CERT_REVOCATION_MODE: offline + + steps: + - name: Check out code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + sparse-checkout: | + . + .github + dotnet + + - name: Get changed files + id: changed-files + if: github.event_name == 'pull_request' + uses: jitterbit/get-changed-files@v1 + continue-on-error: true + + - name: No C# files changed + id: no-csharp + if: github.event_name == 'pull_request' && steps.changed-files.outputs.added_modified == '' + run: echo "No C# files changed" + + # This step will loop over the changed files and find the nearest .csproj file for each one, then store the unique csproj files in a variable + - name: Find csproj files + id: find-csproj + if: github.event_name != 'pull_request' || steps.changed-files.outputs.added_modified != '' || steps.changed-files.outcome == 'failure' + run: | + csproj_files=() + exclude_files=("Experimental.Orchestration.Flow.csproj" "Experimental.Orchestration.Flow.UnitTests.csproj" "Experimental.Orchestration.Flow.IntegrationTests.csproj") + if [[ ${{ steps.changed-files.outcome }} == 'success' ]]; then + for file in ${{ steps.changed-files.outputs.added_modified }}; do + echo "$file was changed" + dir="./$file" + while [[ $dir != "." && $dir != "/" && $dir != $GITHUB_WORKSPACE ]]; do + if find "$dir" -maxdepth 1 -name "*.csproj" -print -quit | grep -q .; then + csproj_path="$(find "$dir" -maxdepth 1 -name "*.csproj" -print -quit)" + if [[ ! "${exclude_files[@]}" =~ "${csproj_path##*/}" ]]; then + csproj_files+=("$csproj_path") + fi + break + fi + + dir=$(echo ${dir%/*}) + done + done + else + # if the changed-files step failed, run dotnet on the whole slnx instead of specific projects + csproj_files=$(find ./ -type f -name "*.slnx" | tr '\n' ' '); + fi + csproj_files=($(printf "%s\n" "${csproj_files[@]}" | sort -u)) + echo "Found ${#csproj_files[@]} unique csproj/slnx files: ${csproj_files[*]}" + echo "csproj_files=${csproj_files[*]}" >> $GITHUB_OUTPUT + + - name: Pull container dotnet/sdk:${{ matrix.dotnet }} + if: steps.find-csproj.outputs.csproj_files != '' + run: docker pull mcr.microsoft.com/dotnet/sdk:${{ matrix.dotnet }} + + # This step will run dotnet format on each of the unique csproj files and fail if any changes are made + - name: Run dotnet format + if: steps.find-csproj.outputs.csproj_files != '' + run: | + for csproj in ${{ steps.find-csproj.outputs.csproj_files }}; do + echo "Running dotnet format on $csproj" + docker run --rm -v $(pwd):/app -w /app mcr.microsoft.com/dotnet/sdk:${{ matrix.dotnet }} /bin/sh -c "dotnet format $csproj --verify-no-changes --verbosity diagnostic" + done