fix: Enhance Windows microphone detection and improve error handling … #127
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
| # PR Validation Workflow | |
| # | |
| # This workflow validates pull requests to the main branch by running tests, | |
| # checking code coverage, and enforcing quality gates before allowing merge. | |
| # | |
| # Triggers: Pull requests targeting 'main' branch | |
| # Performance Target: ≤10 minutes total | |
| # Coverage Strategy: Prevent regression (no decrease), track line coverage | |
| name: PR Validation | |
| on: | |
| push: | |
| branches: [main] # Build on main to populate cache for PRs | |
| pull_request: | |
| branches: [main] | |
| types: [opened, synchronize, reopened] | |
| # Cancel in-progress runs for the same PR to save resources | |
| concurrency: | |
| group: pr-validation-${{ github.ref }} | |
| cancel-in-progress: true | |
| # Environment variables available to all jobs | |
| env: | |
| DOTNET_VERSION: '10.0.x' | |
| # Maximum allowed coverage decrease (percentage points) | |
| # PRs that decrease coverage by more than this will fail | |
| MAX_COVERAGE_DECREASE: 0.5 | |
| jobs: | |
| select-runner: | |
| name: Select Linux Runner | |
| runs-on: ubuntu-latest | |
| outputs: | |
| linux: ${{ steps.detect.outputs.linux }} | |
| is-self-hosted: ${{ steps.detect.outputs.is-self-hosted }} | |
| steps: | |
| - name: Detect available self-hosted runners | |
| id: detect | |
| env: | |
| GH_TOKEN: ${{ secrets.RUNNER_QUERY_TOKEN }} | |
| run: | | |
| echo "🔍 Checking for online self-hosted Linux runners" | |
| # Fallback to ubuntu-latest if token not available or API fails | |
| if [ -z "$GH_TOKEN" ]; then | |
| echo "⚠️ RUNNER_QUERY_TOKEN secret not configured; using ubuntu-latest" | |
| echo "linux=ubuntu-latest" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| if ! RUNNERS=$(gh api repos/${{ github.repository }}/actions/runners --paginate 2>&1); then | |
| echo "⚠️ Unable to query runner API; using ubuntu-latest" | |
| echo "Error: $RUNNERS" | |
| echo "linux=ubuntu-latest" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| # Debug: show what we got | |
| echo "📊 Runner API response:" | |
| echo "$RUNNERS" | jq -r '.runners[]? | " - \(.name): status=\(.status), os=\(.os), labels=\(.labels | map(.name) | join(","))"' | |
| # Match runners with status=online, os=Linux (case-insensitive), and self-hosted label | |
| LINUX_COUNT=$(echo "$RUNNERS" | jq '[.runners[]? | select(.status == "online" and (.os | ascii_downcase) == "linux" and (.labels[]?.name == "self-hosted"))] | length') | |
| if [ "$LINUX_COUNT" -gt 0 ]; then | |
| echo "🤖 Found $LINUX_COUNT online self-hosted Linux runner(s); selecting self-hosted" | |
| echo "linux=self-hosted" >> "$GITHUB_OUTPUT" | |
| echo "is-self-hosted=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "☁️ No online self-hosted Linux runners found; using ubuntu-latest" | |
| echo "linux=ubuntu-latest" >> "$GITHUB_OUTPUT" | |
| echo "is-self-hosted=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| # Job 1: Build | |
| # Compiles code and ensures zero warnings | |
| # Performance Target: ≤2 minutes | |
| build: | |
| name: Build | |
| needs: select-runner | |
| runs-on: ${{ needs.select-runner.outputs.linux }} | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 # Full history for better caching | |
| - name: Setup .NET SDK | |
| uses: actions/setup-dotnet@v4 | |
| with: | |
| dotnet-version: ${{ env.DOTNET_VERSION }} | |
| - name: Cache NuGet packages | |
| # Skip for self-hosted runners - packages persist locally, no need for remote cache | |
| if: needs.select-runner.outputs.is-self-hosted != 'true' | |
| uses: actions/cache@v4 | |
| with: | |
| path: ~/.nuget/packages | |
| # v2: Fresh cache after fixing RuntimeIdentifier-based package selection | |
| key: ${{ runner.os }}-nuget-v2-${{ hashFiles('**/*.csproj') }} | |
| restore-keys: | | |
| ${{ runner.os }}-nuget-v2- | |
| - name: Restore dependencies | |
| run: dotnet restore -r linux-x64 | |
| # -r linux-x64: Triggers platform-specific Whisper.net.Runtime (~200MB) | |
| # instead of Whisper.net.AllRuntimes (~2GB). See csproj RuntimeIdentifier conditions. | |
| - name: Build solution | |
| run: | | |
| dotnet build \ | |
| --configuration Release \ | |
| --no-restore \ | |
| --warnaserror | |
| # --no-restore: Uses packages from restore step | |
| # --warnaserror: Treat warnings as errors | |
| # Job 2: Test | |
| # Runs all unit and integration tests | |
| # Performance Target: ≤5 minutes | |
| # Depends on: build (ensures code compiles before testing) | |
| test: | |
| name: Test | |
| runs-on: ${{ needs.select-runner.outputs.linux }} | |
| needs: [select-runner, build] | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Setup .NET SDK | |
| uses: actions/setup-dotnet@v4 | |
| with: | |
| dotnet-version: ${{ env.DOTNET_VERSION }} | |
| - name: Cache NuGet packages | |
| if: needs.select-runner.outputs.is-self-hosted != 'true' | |
| uses: actions/cache@v4 | |
| with: | |
| path: ~/.nuget/packages | |
| # v2: Fresh cache after fixing RuntimeIdentifier-based package selection | |
| key: ${{ runner.os }}-nuget-v2-${{ hashFiles('**/*.csproj') }} | |
| restore-keys: | | |
| ${{ runner.os }}-nuget-v2- | |
| - name: Restore dependencies | |
| run: dotnet restore -r linux-x64 | |
| # -r linux-x64: Platform-specific packages. See csproj RuntimeIdentifier conditions. | |
| - name: Run tests | |
| run: | | |
| dotnet test \ | |
| --configuration Release \ | |
| --no-restore \ | |
| --logger "trx;LogFileName=test-results.trx" \ | |
| --verbosity normal | |
| - name: Upload test results | |
| if: always() # Upload even if tests fail for debugging | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: test-results | |
| path: '**/TestResults/*.trx' | |
| retention-days: 7 | |
| # Job 3: Coverage | |
| # Calculates code coverage and enforces 80% threshold | |
| # Performance Target: ≤3 minutes | |
| # Depends on: build (needs compiled code for coverage analysis) | |
| coverage: | |
| name: Code Coverage | |
| runs-on: ${{ needs.select-runner.outputs.linux }} | |
| needs: [select-runner, build] | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 # Full history for baseline comparison | |
| - name: Setup .NET SDK | |
| uses: actions/setup-dotnet@v4 | |
| with: | |
| dotnet-version: ${{ env.DOTNET_VERSION }} | |
| - name: Cache NuGet packages | |
| if: needs.select-runner.outputs.is-self-hosted != 'true' | |
| uses: actions/cache@v4 | |
| with: | |
| path: ~/.nuget/packages | |
| # v2: Fresh cache after fixing RuntimeIdentifier-based package selection | |
| key: ${{ runner.os }}-nuget-v2-${{ hashFiles('**/*.csproj') }} | |
| restore-keys: | | |
| ${{ runner.os }}-nuget-v2- | |
| - name: Restore dependencies | |
| run: dotnet restore -r linux-x64 | |
| # -r linux-x64: Platform-specific packages. See csproj RuntimeIdentifier conditions. | |
| - name: Run tests with coverage | |
| run: | | |
| dotnet test \ | |
| --configuration Release \ | |
| --no-restore \ | |
| --collect:"XPlat Code Coverage" \ | |
| --results-directory ./coverage | |
| - name: Install ReportGenerator | |
| run: | | |
| dotnet tool install --global dotnet-reportgenerator-globaltool | |
| echo "$HOME/.dotnet/tools" >> $GITHUB_PATH | |
| - name: Generate coverage report | |
| run: | | |
| reportgenerator \ | |
| -reports:"./coverage/**/coverage.cobertura.xml" \ | |
| -targetdir:"./coverage/report" \ | |
| -reporttypes:"Html;Cobertura;TextSummary" | |
| - name: Parse coverage percentage | |
| id: coverage | |
| run: | | |
| # Extract line coverage percentage from Cobertura XML | |
| COVERAGE=$(grep -oP 'line-rate="\K[0-9.]+' ./coverage/report/Cobertura.xml | head -1) | |
| # Convert to percentage using awk (no bc dependency) | |
| COVERAGE_PERCENT=$(awk "BEGIN {printf \"%.0f\", $COVERAGE * 100}") | |
| echo "percentage=$COVERAGE_PERCENT" >> $GITHUB_OUTPUT | |
| echo "📊 Line Coverage: $COVERAGE_PERCENT%" | |
| # Display full coverage summary (line, branch, method) | |
| cat ./coverage/report/Summary.txt | |
| # Retrieve baseline coverage from cache for comparison | |
| - name: Restore baseline coverage | |
| uses: actions/cache@v4 | |
| id: baseline-cache | |
| with: | |
| path: ./coverage/baseline.txt | |
| key: coverage-baseline-${{ github.base_ref }} | |
| - name: Check coverage regression | |
| if: steps.baseline-cache.outputs.cache-hit == 'true' | |
| run: | | |
| CURRENT_COVERAGE=${{ steps.coverage.outputs.percentage }} | |
| BASELINE_COVERAGE=$(cat ./coverage/baseline.txt || echo "0") | |
| MAX_DECREASE=${{ env.MAX_COVERAGE_DECREASE }} | |
| # Calculate the difference using awk (no bc dependency) | |
| DIFF=$(awk "BEGIN {printf \"%.1f\", $CURRENT_COVERAGE - $BASELINE_COVERAGE}") | |
| echo "📊 Current line coverage: $CURRENT_COVERAGE%" | |
| echo "📊 Baseline line coverage: $BASELINE_COVERAGE%" | |
| echo "📊 Change: $DIFF percentage points" | |
| echo "📊 Max allowed decrease: -$MAX_DECREASE percentage points" | |
| # Check if coverage decreased more than allowed using awk | |
| REGRESSION=$(awk "BEGIN {print ($DIFF < -$MAX_DECREASE) ? 1 : 0}") | |
| if [ "$REGRESSION" -eq 1 ]; then | |
| echo "❌ Coverage regression detected: decreased by $DIFF percentage points" | |
| echo "Please add tests to maintain or improve coverage." | |
| exit 1 | |
| else | |
| echo "✅ Coverage check passed: no significant regression" | |
| # Check if coverage improved | |
| IMPROVED=$(awk "BEGIN {print ($DIFF > 0) ? 1 : 0}") | |
| if [ "$IMPROVED" -eq 1 ]; then | |
| echo "🎉 Coverage improved by $DIFF percentage points!" | |
| fi | |
| fi | |
| - name: Upload coverage report | |
| if: always() # Upload even if threshold check fails | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: coverage-report | |
| path: ./coverage/report/ | |
| retention-days: 30 | |
| - name: Calculate coverage diff | |
| id: coverage-diff | |
| if: steps.baseline-cache.outputs.cache-hit == 'true' | |
| run: | | |
| CURRENT_COVERAGE=${{ steps.coverage.outputs.percentage }} | |
| BASELINE_COVERAGE=$(cat ./coverage/baseline.txt || echo "0") | |
| DIFF=$((CURRENT_COVERAGE - BASELINE_COVERAGE)) | |
| echo "diff=$DIFF" >> $GITHUB_OUTPUT | |
| echo "baseline=$BASELINE_COVERAGE" >> $GITHUB_OUTPUT | |
| echo "Coverage diff: ${DIFF}% (baseline: ${BASELINE_COVERAGE}%, current: ${CURRENT_COVERAGE}%)" | |
| - name: Comment PR with coverage diff | |
| if: | | |
| steps.coverage-diff.outputs.diff != '' && | |
| (steps.coverage-diff.outputs.diff >= 1 || steps.coverage-diff.outputs.diff <= -1) | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const diff = ${{ steps.coverage-diff.outputs.diff }}; | |
| const baseline = ${{ steps.coverage-diff.outputs.baseline }}; | |
| const current = ${{ steps.coverage.outputs.percentage }}; | |
| const maxDecrease = ${{ env.MAX_COVERAGE_DECREASE }}; | |
| const emoji = diff >= 0 ? '📈' : '📉'; | |
| const sign = diff >= 0 ? '+' : ''; | |
| const regressionCheck = diff >= -maxDecrease; | |
| const status = regressionCheck ? '✅' : '❌'; | |
| const body = `## ${emoji} Code Coverage Report\n\n` + | |
| `${status} **Current Line Coverage:** ${current}%\n` + | |
| `📊 **Baseline Coverage:** ${baseline}%\n` + | |
| `${emoji} **Change:** ${sign}${diff} percentage points\n\n` + | |
| `**Regression Check:** ` + (regressionCheck ? '✅ Passed' : `❌ Failed (decreased by more than ${maxDecrease}%)`) + '\n\n' + | |
| `*Line coverage is the primary metric. See artifacts for branch and method coverage details.*\n\n` + | |
| `<details>\n<summary>📊 Full Coverage Report</summary>\n\n` + | |
| `Download the complete coverage report (HTML, line/branch/method metrics) from the workflow artifacts.\n` + | |
| `</details>`; | |
| github.rest.issues.createComment({ | |
| issue_number: context.issue.number, | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| body: body | |
| }); | |
| - name: Save current coverage as baseline | |
| if: github.event.pull_request.merged == true | |
| run: | | |
| echo "${{ steps.coverage.outputs.percentage }}" > ./coverage/baseline.txt | |
| - name: Update baseline cache | |
| if: github.event.pull_request.merged == true | |
| uses: actions/cache/save@v4 | |
| with: | |
| path: ./coverage/baseline.txt | |
| key: coverage-baseline-main | |
| # Job 4: Verify Release Build | |
| # Verifies that release builds will work when tagged | |
| # Performance Target: ≤3 minutes | |
| # Builds one platform to ensure release.yml will succeed | |
| verify-release-build: | |
| name: Verify Release Build | |
| runs-on: ${{ needs.select-runner.outputs.linux }} | |
| needs: [select-runner, build] | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@v4 | |
| - name: Setup .NET SDK | |
| uses: actions/setup-dotnet@v4 | |
| with: | |
| dotnet-version: ${{ env.DOTNET_VERSION }} | |
| - name: Restore dependencies | |
| run: dotnet restore -r linux-x64 | |
| # -r linux-x64: Platform-specific packages. See csproj RuntimeIdentifier conditions. | |
| - name: Build release executable (Linux x64) | |
| run: | | |
| # Use PR number for PRs, run number for push to main | |
| VERSION="0.0.0-pr.${{ github.event.pull_request.number || github.run_number }}" | |
| echo "📦 Building with version: $VERSION" | |
| dotnet publish src/TenSecondTom.csproj \ | |
| --configuration Release \ | |
| --runtime linux-x64 \ | |
| --self-contained true \ | |
| --output ./publish \ | |
| -p:PublishSingleFile=true \ | |
| -p:Version=$VERSION | |
| - name: Verify build output | |
| run: | | |
| if [ ! -f "./publish/tom" ]; then | |
| echo "❌ Error: Release build did not produce executable" | |
| exit 1 | |
| fi | |
| SIZE=$(stat -c%s ./publish/tom) | |
| SIZE_MB=$((SIZE / 1024 / 1024)) | |
| echo "📦 Executable size: ${SIZE_MB}MB" | |
| if [ $SIZE_MB -gt 50 ]; then | |
| echo "⚠️ Warning: Executable size exceeds 50MB" | |
| fi | |
| echo "✅ Release build verification passed" | |
| - name: Smoke test | |
| run: | | |
| chmod +x ./publish/tom | |
| ./publish/tom --version | |
| if [ $? -eq 0 ]; then | |
| echo "✅ Release executable runs successfully" | |
| else | |
| echo "❌ Release executable failed to run" | |
| exit 1 | |
| fi | |
| # Job 5: Validate | |
| # Aggregates status from all previous jobs | |
| # This job ensures all quality gates passed before allowing merge | |
| validate: | |
| name: Validation Complete | |
| runs-on: ${{ needs.select-runner.outputs.linux }} | |
| needs: [select-runner, build, test, coverage, verify-release-build] | |
| steps: | |
| - name: All checks passed | |
| run: | | |
| echo "✅ All validation checks passed successfully!" | |
| echo " - Build: Completed with zero warnings" | |
| echo " - Tests: All tests passed" | |
| echo " - Coverage: No significant regression detected" | |
| echo " - Release Build: Verified and smoke tested" | |
| echo "" | |
| echo "Pull request is ready to merge." |