TUnit Only #824
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
| name: TUnit Only | |
| on: | |
| schedule: | |
| # Runs at 01:00, 07:00, 13:00, 19:00 UTC daily (4× per day) | |
| - cron: "0 1,7,13,19 * * *" | |
| push: | |
| paths: | |
| - ".github/workflows/tunit-pipeline.yml" | |
| - "Saucery.TUnit/**" | |
| - "Merlin.TUnit/**" | |
| - "Merlin.TUnit.RealDevices/**" | |
| branches: | |
| - master | |
| - "releases/**" | |
| - "feat/**" | |
| - "feature/**" | |
| - "fix/**" | |
| - "test/**" | |
| - "bug/**" | |
| workflow_dispatch: | |
| env: | |
| PROJECT_NAME: Saucery | |
| SOLN_FILE: Saucery.sln | |
| TUNIT_INT_DIR: Merlin.TUnit | |
| TUNIT_RD_INT_DIR: Merlin.TUnit.RealDevices | |
| COVERLET_DIR: ./TestResults | |
| UT_PROJECT: Saucery.Core.Tests/Saucery.Core.Tests.csproj | |
| TT_PROJECT: Template.Tests/Template.Tests.csproj | |
| M_TUNIT_PROJECT: Merlin.TUnit/Merlin.TUnit.csproj | |
| M_RD_TUNIT_PROJECT: Merlin.TUnit.RealDevices/Merlin.TUnit.RealDevices.csproj | |
| SAUCE_USER_NAME: ${{ secrets.SAUCE_USER_NAME }} | |
| SAUCE_API_KEY: ${{ secrets.SAUCE_API_KEY }} | |
| CODACY_PROJECT_TOKEN: ${{ secrets.CODACY_PROJECT_TOKEN }} | |
| CONFIG: Release | |
| APP_PACKAGE_PATH: published | |
| COVERAGE_FORMAT: cobertura | |
| UNIT_COVERAGE_FILENAME: cobertura.unit.xml | |
| TUNIT_INTEGRATION_COVERAGE_FILENAME: cobertura.tunit.integration.xml | |
| TUNIT_RD_INTEGRATION_COVERAGE_FILENAME: cobertura.tunit.realdevice.integration.xml | |
| COVERAGE_THRESHOLD: 80 | |
| # Newline-separated patterns (script splits on newline OR comma) | |
| WATCHED_GLOBS: | | |
| .github/workflows/tunit-pipeline.yml | |
| Saucery.TUnit/** | |
| Merlin.TUnit/** | |
| Merlin.TUnit.RealDevices/** | |
| jobs: | |
| detect-changes: | |
| name: Detect if watched paths changed since last completed run | |
| runs-on: ubuntu-latest | |
| permissions: | |
| actions: read | |
| contents: read | |
| outputs: | |
| # Keep both for clarity; downstream uses paths_changed | |
| changed: ${{ steps.decide.outputs.changed }} | |
| previous_sha: ${{ steps.decide.outputs.previous_sha }} | |
| paths_changed: ${{ steps.decide.outputs.paths_changed }} | |
| steps: | |
| - name: Resolve last completed run head_sha for this workflow+branch AND check watched paths | |
| id: decide | |
| uses: actions/github-script@v7 | |
| with: | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| script: | | |
| const { owner, repo } = context.repo; | |
| // On schedule, context.ref will typically be the default branch (e.g. refs/heads/master) | |
| const branch = (context.ref || "").replace("refs/heads/", ""); | |
| const workflow_id = ".github/workflows/tunit-pipeline.yml"; | |
| const runs = await github.rest.actions.listWorkflowRuns({ | |
| owner, | |
| repo, | |
| workflow_id, | |
| branch, | |
| status: "completed", | |
| per_page: 1 | |
| }); | |
| const prevSha = runs.data.workflow_runs[0]?.head_sha || ""; | |
| const curSha = context.sha; | |
| core.info(`Branch: ${branch || "<unknown>"}`); | |
| core.info(`Current SHA: ${curSha}`); | |
| core.info(`Previous SHA: ${prevSha || "<none>"}`); | |
| // Outputs are strings | |
| let changed = "true"; | |
| let pathsChanged = "true"; | |
| // Watched globs: allow newline or comma-separated | |
| const raw = process.env.WATCHED_GLOBS || ""; | |
| const watchedGlobs = raw | |
| .split(/\r?\n|,/g) | |
| .map(x => x.trim()) | |
| .filter(x => x.length > 0); | |
| core.info(`WATCHED_GLOBS (parsed): [${watchedGlobs.join(", ")}]`); | |
| // Very small glob matcher supporting '*' and '**' | |
| function globToRegexSource(pattern) { | |
| let out = ""; | |
| for (let i = 0; i < pattern.length; i++) { | |
| const ch = pattern[i]; | |
| if (ch === "*") { | |
| const nextIsStar = i + 1 < pattern.length && pattern[i + 1] === "*"; | |
| if (nextIsStar) { | |
| out += ".*"; // '**' => any depth | |
| i++; // skip second '*' | |
| } else { | |
| out += "[^/]*"; // '*' => any chars except '/' | |
| } | |
| } else if (ch === "/") { | |
| out += "\\/"; // literal slash | |
| } else if ("\\.[]{}()+-?^$|".includes(ch)) { | |
| out += "\\" + ch; // escape regex special | |
| } else { | |
| out += ch; | |
| } | |
| } | |
| return out; | |
| } | |
| function matchesGlob(path, pattern) { | |
| const source = globToRegexSource(pattern); | |
| const regex = new RegExp("^" + source + "$"); | |
| return regex.test(path); | |
| } | |
| if (!prevSha) { | |
| core.info("No previous completed run for this workflow+branch. Treating as changed."); | |
| changed = "true"; | |
| pathsChanged = "true"; | |
| } else if (prevSha === curSha) { | |
| core.info("No new commits since the last completed run on this branch."); | |
| changed = "false"; | |
| pathsChanged = "false"; | |
| } else { | |
| // There are new commits; now see if watched paths changed. | |
| const comparison = await github.rest.repos.compareCommits({ | |
| owner, | |
| repo, | |
| base: prevSha, | |
| head: curSha | |
| }); | |
| const files = comparison.data.files || []; | |
| const changedPaths = files.map(f => f.filename); | |
| core.info("Changed files since last run:"); | |
| if (changedPaths.length === 0) { | |
| core.info(" (none reported by compareCommits)"); | |
| } else { | |
| for (const p of changedPaths) core.info(` - ${p}`); | |
| } | |
| if (watchedGlobs.length === 0) { | |
| core.info("No watched globs defined; treating paths_changed as true."); | |
| pathsChanged = "true"; | |
| } else { | |
| const matches = changedPaths.some(path => | |
| watchedGlobs.some(pattern => matchesGlob(path, pattern)) | |
| ); | |
| pathsChanged = matches ? "true" : "false"; | |
| if (pathsChanged === "true") { | |
| core.info("Detected changes in watched paths. Downstream jobs are eligible to run."); | |
| } else { | |
| core.info("No changes in watched paths. Downstream jobs will be skipped."); | |
| } | |
| } | |
| // This reflects “new commits exist”; keep for visibility | |
| changed = "true"; | |
| } | |
| core.setOutput("changed", changed); | |
| core.setOutput("paths_changed", pathsChanged); | |
| core.setOutput("previous_sha", prevSha); | |
| build: | |
| runs-on: ubuntu-latest | |
| needs: detect-changes | |
| if: needs.detect-changes.outputs.paths_changed == 'true' | |
| steps: | |
| - name: Checkout Code | |
| uses: actions/checkout@v5 | |
| - name: Setup dotnet | |
| uses: actions/setup-dotnet@v5 | |
| with: | |
| dotnet-version: 10.0.x | |
| - name: Compile solution | |
| run: dotnet build -c ${{ env.CONFIG }} | |
| - name: Run TUnit Unit tests (with coverage + TRX) | |
| run: | | |
| dotnet run -c ${{ env.CONFIG }} --project ${{ env.UT_PROJECT }} -- \ | |
| --report-trx \ | |
| --report-trx-filename "unit.trx" \ | |
| --coverage \ | |
| --coverage-output "${{ github.workspace }}/${{ env.COVERLET_DIR }}/${{ env.UNIT_COVERAGE_FILENAME }}" \ | |
| --coverage-output-format "${{ env.COVERAGE_FORMAT }}" \ | |
| --results-directory "${{ env.COVERLET_DIR }}" \ | |
| --log-level Debug | |
| - name: Run Template Tests | |
| run: dotnet run --project ${{ env.TT_PROJECT }} -- --treenode-filter '/*/*/*/*[Category=TUnit]' | |
| - name: Display structure of downloaded files | |
| run: ls -R | |
| - name: Upload TRX | |
| uses: actions/upload-artifact@v5 | |
| with: | |
| name: unit-test-trx | |
| path: ${{ env.COVERLET_DIR }}/unit.trx | |
| - name: Publish Unit Coverage Artifact | |
| uses: actions/upload-artifact@v5 | |
| with: | |
| name: unit-test-results | |
| path: ${{ env.COVERLET_DIR }}/${{ env.UNIT_COVERAGE_FILENAME }} | |
| tunit-integration-tests: | |
| runs-on: ubuntu-latest | |
| needs: build | |
| # Not strictly necessary (needs: build will skip if build skipped), | |
| # but explicit and protects you if you ever add `if: always()` upstream. | |
| if: needs.build.result == 'success' | |
| steps: | |
| - name: Checkout Code | |
| uses: actions/checkout@v5 | |
| - name: Setup dotnet | |
| uses: actions/setup-dotnet@v5 | |
| with: | |
| dotnet-version: 10.0.x | |
| - name: Compile solution | |
| run: dotnet build -c ${{ env.CONFIG }} | |
| - name: Run TUnit Integration Tests | |
| run: | | |
| dotnet run -c ${{ env.CONFIG }} --project ${{ env.M_TUNIT_PROJECT }} -- \ | |
| --coverage \ | |
| --coverage-output "${{ github.workspace }}/${{ env.COVERLET_DIR }}/${{ env.TUNIT_INTEGRATION_COVERAGE_FILENAME }}" \ | |
| --coverage-output-format "${{ env.COVERAGE_FORMAT }}" \ | |
| --results-directory "${{ env.COVERLET_DIR }}" \ | |
| --log-level Debug | |
| - name: Display structure of downloaded files | |
| run: ls -R | |
| - name: Publish Artifacts | |
| uses: actions/upload-artifact@v5 | |
| with: | |
| name: tunit-integration-test-results | |
| path: ${{ env.COVERLET_DIR }}/${{ env.TUNIT_INTEGRATION_COVERAGE_FILENAME }} | |
| tunit-real-integration-tests: | |
| runs-on: ubuntu-latest | |
| needs: build | |
| if: needs.build.result == 'success' | |
| steps: | |
| - name: Checkout Code | |
| uses: actions/checkout@v5 | |
| - name: Setup dotnet | |
| uses: actions/setup-dotnet@v5 | |
| with: | |
| dotnet-version: 10.0.x | |
| - name: Compile solution | |
| run: dotnet build -c ${{ env.CONFIG }} | |
| - name: Run TUnit Real Integration Tests | |
| run: | | |
| dotnet run -c ${{ env.CONFIG }} --project ${{ env.M_RD_TUNIT_PROJECT }} -- \ | |
| --coverage \ | |
| --coverage-output "${{ github.workspace }}/${{ env.COVERLET_DIR }}/${{ env.TUNIT_RD_INTEGRATION_COVERAGE_FILENAME }}" \ | |
| --coverage-output-format "${{ env.COVERAGE_FORMAT }}" \ | |
| --results-directory "${{ env.COVERLET_DIR }}" \ | |
| --log-level Debug | |
| - name: Publish Artifacts | |
| uses: actions/upload-artifact@v5 | |
| with: | |
| name: tunit-real-integration-test-results | |
| path: ${{ env.COVERLET_DIR }}/${{ env.TUNIT_RD_INTEGRATION_COVERAGE_FILENAME }} | |
| # Publish Saucery.TUnit to NuGet if newer (only master + only if tests ran) | |
| publish-saucery-tunit: | |
| runs-on: ubuntu-latest | |
| needs: [tunit-integration-tests, tunit-real-integration-tests] | |
| if: github.ref == 'refs/heads/master' | |
| env: | |
| PROJECT_PATH: Saucery.TUnit/Saucery.TUnit.csproj | |
| PACKAGE_ID: Saucery.TUnit | |
| BUILD_CONFIGURATION: Release | |
| ARTIFACT_DIR: artifacts | |
| steps: | |
| - name: Checkout Code | |
| uses: actions/checkout@v5 | |
| - name: Setup dotnet | |
| uses: actions/setup-dotnet@v5 | |
| with: | |
| dotnet-version: 10.0.x | |
| - name: Build package | |
| run: | | |
| dotnet restore "$PROJECT_PATH" | |
| dotnet build "$PROJECT_PATH" -c "$BUILD_CONFIGURATION" --no-restore | |
| mkdir -p "$ARTIFACT_DIR" | |
| dotnet pack "$PROJECT_PATH" -c "$BUILD_CONFIGURATION" -o "$ARTIFACT_DIR" --no-build | |
| - name: Determine local packed version | |
| id: localver | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| NUPKG="$(ls "$ARTIFACT_DIR"/${PACKAGE_ID}.*.nupkg | grep -v '\.symbols\.nupkg' | head -n1)" | |
| BASENAME="$(basename "$NUPKG")" | |
| LOCAL_VERSION="${BASENAME#${PACKAGE_ID}.}" | |
| LOCAL_VERSION="${LOCAL_VERSION%.nupkg}" | |
| echo "nupkg=$NUPKG" >> "$GITHUB_OUTPUT" | |
| echo "version=$LOCAL_VERSION" >> "$GITHUB_OUTPUT" | |
| echo "Local version: $LOCAL_VERSION" | |
| - name: Query NuGet for latest published version | |
| id: remotever | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| LOWER_ID="$(echo "$PACKAGE_ID" | tr '[:upper:]' '[:lower:]')" | |
| INDEX_URL="https://api.nuget.org/v3-flatcontainer/${LOWER_ID}/index.json" | |
| if ! curl -fsSL "$INDEX_URL" -o /tmp/nuget_index.json; then | |
| echo "version=" >> "$GITHUB_OUTPUT" | |
| echo "Package not found on NuGet (first publish)." | |
| exit 0 | |
| fi | |
| REMOTE_VERSION="$(jq -r '.versions | last // empty' /tmp/nuget_index.json)" | |
| echo "version=$REMOTE_VERSION" >> "$GITHUB_OUTPUT" | |
| echo "Remote version: ${REMOTE_VERSION:-<none>}" | |
| - name: Decide if publish is needed | |
| id: decide | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| LOCAL="${{ steps.localver.outputs.version }}" | |
| REMOTE="${{ steps.remotever.outputs.version }}" | |
| if [[ -z "$REMOTE" ]]; then | |
| echo "publish=true" >> "$GITHUB_OUTPUT" | |
| echo "No remote version; will publish." | |
| exit 0 | |
| fi | |
| HIGHEST="$(printf '%s\n' "$REMOTE" "$LOCAL" | sort -V | tail -n1)" | |
| if [[ "$HIGHEST" == "$LOCAL" && "$LOCAL" != "$REMOTE" ]]; then | |
| echo "publish=true" >> "$GITHUB_OUTPUT" | |
| echo "Local ($LOCAL) > Remote ($REMOTE); will publish." | |
| else | |
| echo "publish=false" >> "$GITHUB_OUTPUT" | |
| echo "Local ($LOCAL) is NOT greater than Remote ($REMOTE); skipping publish." | |
| fi | |
| - name: Publish to NuGet (only if newer) | |
| if: steps.decide.outputs.publish == 'true' | |
| env: | |
| NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} | |
| run: | | |
| set -euo pipefail | |
| if [[ -z "${NUGET_API_KEY:-}" ]]; then | |
| echo "NUGET_API_KEY secret is missing." >&2 | |
| exit 1 | |
| fi | |
| dotnet nuget push "${{ steps.localver.outputs.nupkg }}" \ | |
| --api-key "$NUGET_API_KEY" \ | |
| --source https://api.nuget.org/v3/index.json \ | |
| --skip-duplicate | |
| generate-report: | |
| runs-on: ubuntu-latest | |
| needs: [tunit-integration-tests, tunit-real-integration-tests] | |
| if: needs.tunit-integration-tests.result == 'success' && needs.tunit-real-integration-tests.result == 'success' | |
| steps: | |
| - name: Checkout Code | |
| uses: actions/checkout@v5 | |
| - name: Download unit-test results | |
| uses: actions/download-artifact@v6 | |
| with: | |
| name: unit-test-results | |
| - name: Download tunit-integration-test results | |
| uses: actions/download-artifact@v6 | |
| with: | |
| name: tunit-integration-test-results | |
| - name: Download tunit-real-integration-test results | |
| uses: actions/download-artifact@v6 | |
| with: | |
| name: tunit-real-integration-test-results | |
| - name: ReportGenerator | |
| uses: danielpalme/ReportGenerator-GitHub-Action@5.5.0 | |
| with: | |
| reports: ${{ env.UNIT_COVERAGE_FILENAME }} | |
| targetdir: coveragereport | |
| reporttypes: "HtmlInline;Cobertura" | |
| - name: Upload coverage report artifact | |
| uses: actions/upload-artifact@v5 | |
| if: ${{ always() }} | |
| with: | |
| name: CoverageReport | |
| path: coveragereport |