Skip to content

TUnit Only

TUnit Only #824

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