Skip to content

ci: SHA-pin all 46 action refs + fix 5 template-injection findings #195

ci: SHA-pin all 46 action refs + fix 5 template-injection findings

ci: SHA-pin all 46 action refs + fix 5 template-injection findings #195

Workflow file for this run

# Sequential PR validation workflow with coverage gating
# Stage 1: Linux tests with 90% coverage requirement
# Stage 2: Windows .NET (5.0-10.0) and .NET Framework (4.6.2-4.8.1) tests (only if Linux passes)
# Stage 3: macOS tests (only if Stage 2 passes)
#
# SECURITY NOTE:
# - Uses pull_request_target to run workflow from the trusted main branch, not from the PR branch
# - This prevents malicious workflow YAML changes in untrusted PR branches from taking effect
# - All checkout steps use PR refs (refs/pull/*/head) to check out PR code from the base repo
# - After checkout, configuration files (.editorconfig, BannedSymbols.txt, etc.) are fetched from
# the main branch to prevent malicious PRs from disabling analyzers or bypassing code quality checks
# - If a PR changes any of these protected configuration files, CI explicitly fails with instructions
# for a maintainer to manually review and verify the changes before merging
# - persist-credentials: false prevents the checkout token from being written to git config for subsequent git commands
# (it does NOT, by itself, prevent steps from accessing github.token / GITHUB_TOKEN if you explicitly expose it)
# - Default GITHUB_TOKEN permissions are restricted to read-only repository contents to limit impact if exposed
name: PR Checks v3 (Gated)
permissions:
contents: read
env:
CODECOV_MINIMUM: 90
on:
pull_request_target: # Runs from the main branch, not from PR branch
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
# ============================================================================
# SECRETS SCAN: Detect leaked credentials before merge
# ============================================================================
secrets-scan:
name: "Secrets Scan (gitleaks)"
runs-on: ubuntu-latest
if: github.repository != 'Chris-Wolfgang/repo-template'
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: refs/pull/${{ github.event.pull_request.number }}/head
persist-credentials: false
fetch-depth: 0
- name: Fetch trusted gitleaks config from main
# Prevent PR from modifying .gitleaks.toml to bypass the scan.
# Distinguish "file doesn't exist in main" (fine — gitleaks uses
# defaults) from "checkout failed for any other reason" (abort —
# silently using the PR version would defeat the guard).
shell: bash
run: |
if ! git fetch origin main --depth=1; then
echo "::error::Failed to fetch origin/main — aborting before gitleaks scan."
exit 1
fi
if git cat-file -e origin/main:.gitleaks.toml 2>/dev/null; then
if ! git checkout origin/main -- .gitleaks.toml; then
echo "::error::Failed to checkout origin/main:.gitleaks.toml — aborting to prevent silent fall-back to PR version."
exit 1
fi
else
echo "::notice::.gitleaks.toml not present in origin/main — gitleaks will use defaults."
fi
- name: Run gitleaks
# gitleaks-action@v2 does not support pull_request_target, so invoke the CLI directly
# Pinned to a specific version with SHA256 checksum verification for supply-chain safety
run: |
GITLEAKS_VERSION="8.24.0"
GITLEAKS_SHA256="cb49b7de5ee986510fe8666ca0273a6cc15eb82571f2f14832c9e8920751f3a4"
mkdir -p "$HOME/.local/bin"
TARBALL="$(mktemp)"
curl -sSfL -o "$TARBALL" "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
echo "${GITLEAKS_SHA256} ${TARBALL}" | sha256sum -c - || { echo "Checksum verification failed!"; exit 1; }
tar xzf "$TARBALL" -C "$HOME/.local/bin" gitleaks
rm -f "$TARBALL"
export PATH="$HOME/.local/bin:$PATH"
gitleaks detect --source . --verbose --redact
shell: bash
# ============================================================================
# DETECTION: Check if .csproj files exist
# ============================================================================
detect-projects:
name: "Detect .NET Projects"
runs-on: ubuntu-latest
if: github.repository != 'Chris-Wolfgang/repo-template'
outputs:
has-projects: ${{ steps.check-projects.outputs.has-projects }}
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: refs/pull/${{ github.event.pull_request.number }}/head
persist-credentials: false
- name: Fetch trusted configuration files from main branch
# Skip for Dependabot — its package-version bumps to protected files (e.g.
# Directory.Build.props) are legitimate and should not be overwritten by main's
# older versions. Dependabot's identity is GitHub-controlled and not spoofable.
if: github.event.pull_request.user.login != 'dependabot[bot]'
run: |
echo "Fetching configuration files from main branch to prevent malicious overrides..."
# Fetch the main branch
git fetch origin main:main-branch
# List of configuration files that should come from trusted main branch
config_files=(
".editorconfig"
"Directory.Build.props"
"Directory.Build.targets"
"BannedSymbols.txt"
"*.globalconfig"
"*.ruleset"
"*.DotSettings"
".github/workflows/*.yml"
".github/workflows/*.yaml"
)
# Copy each configuration file from main branch if it exists
for config_file in "${config_files[@]}"; do
# Handle glob patterns
if [[ "$config_file" == *"*"* ]]; then
# Find files matching the pattern in main branch.
# NOTE: use process substitution (`done < <(...)`) instead of a
# plain pipeline. A piped `while` runs in a subshell — an
# `exit 1` from inside would only kill the subshell, not the
# outer step, letting a failed copy silently fall back to the
# PR-supplied protected config. Process substitution runs the
# loop in the parent shell so exit actually terminates the job.
while read -r file; do
if [ -n "$file" ]; then
echo " ✓ Copying $file from main branch"
mkdir -p "$(dirname "$file")"
if ! git show "main-branch:$file" > "$file"; then
echo "::error::Failed to copy $file from main-branch — aborting to prevent silent fall-back to PR-supplied protected config."
exit 1
fi
fi
# Mask grep's exit 1 on zero matches with `|| true` — under
# `set -eo pipefail`, an empty match would otherwise fail the step,
# but a pattern like `*.ruleset` legitimately has no matches in
# repos that don't ship one. The empty stream is fine; the while
# loop simply doesn't iterate.
done < <(git ls-tree -r --name-only main-branch | { grep -E "${config_file//\*/.*}" || true; })
else
# Check if file exists in main branch
if git cat-file -e "main-branch:$config_file" 2>/dev/null; then
echo " ✓ Copying $config_file from main branch"
if ! git show "main-branch:$config_file" > "$config_file"; then
echo "::error::Failed to copy $config_file from main-branch — aborting to prevent silent fall-back to PR-supplied protected config."
exit 1
fi
else
echo " ℹ️ $config_file not found in main branch, skipping"
fi
fi
done
echo ""
echo "✅ Configuration files secured - using versions from main branch"
- name: Detect protected configuration file changes
# Skip for Dependabot — its bumps to protected files (e.g. Directory.Build.props)
# are legitimate. The guard's threat model is human PR authors disabling analyzers
# in their own PRs; it does not apply to a trusted GitHub-controlled bot whose
# only action is package-version updates.
if: github.event.pull_request.user.login != 'dependabot[bot]'
run: |
echo "Checking for changes to protected configuration files in this PR..."
# Verify main-branch ref is available (it was fetched in the previous step)
if ! git cat-file -e main-branch 2>/dev/null; then
echo "❌ main-branch ref not found - cannot detect configuration file changes"
exit 1
fi
changed_files=()
# Check exact file matches against main branch git objects
# 2>/dev/null suppresses output when a file doesn't exist in one ref (new/deleted file),
# which git diff handles correctly via its exit code
exact_files=(
".editorconfig"
"Directory.Build.props"
"Directory.Build.targets"
"BannedSymbols.txt"
)
for config_file in "${exact_files[@]}"; do
if ! git diff --quiet main-branch HEAD -- "$config_file" 2>/dev/null; then
changed_files+=("$config_file")
fi
done
# Check .globalconfig, .ruleset, and workflow files using the same git diff approach
# --diff-filter=AMRCD: Added, Modified, Renamed, Copied, Deleted.
# Including D so a PR that *deletes* a protected file (workflow,
# .globalconfig, .ruleset) also triggers the maintainer-review gate
# — a silent deletion is just as security-relevant as a silent edit.
while IFS= read -r file; do
changed_files+=("$file")
done < <(git diff --name-only --diff-filter=AMRCD main-branch HEAD 2>/dev/null | grep -E '(\.(globalconfig|ruleset)|^\.github/workflows/.*\.ya?ml)$' || true)
if [ ${#changed_files[@]} -gt 0 ]; then
echo ""
echo "⚠️ PROTECTED CONFIGURATION FILES CHANGED IN THIS PR:"
for file in "${changed_files[@]}"; do
echo " - $file"
done
echo ""
echo "❌ CI uses the main branch version of these files to prevent security bypasses."
echo " The PR's changes to these files were NOT tested by CI."
echo " A maintainer must manually review and verify these changes before merging."
echo ""
echo "To proceed, a maintainer should:"
echo " 1. Review the configuration changes in this PR carefully"
echo " 2. Test the changes locally to confirm they work correctly"
echo " 3. Merge with awareness that CI did not validate these configuration changes"
exit 1
else
echo "✅ No protected configuration files changed - CI fully validates this PR"
fi
- name: Check for .NET project files
id: check-projects
run: |
if git ls-files '*.csproj' '*.vbproj' '*.fsproj' | grep -q .; then
echo "has-projects=true" >> "$GITHUB_OUTPUT"
echo "✅ Found .NET project files - .NET build and test jobs will run"
else
echo "has-projects=false" >> "$GITHUB_OUTPUT"
echo "ℹ️ No .NET project files found - skipping .NET build and test jobs"
fi
# ============================================================================
# INSPECTCODE: JetBrains ReSharper InspectCode parallel to the test stages.
# Runs concurrently with Stage 1 / 2 / 3 (no `needs:` on a test job) so it
# doesn't extend wall-clock — typically 3–5 min vs the slowest stage.
# SARIF uploads to GitHub Code Scanning (Security tab + inline PR
# annotations). `error`-severity findings fail the job; `warning`-severity
# surfaces in Code Scanning but doesn't fail (gated by --severity=WARNING
# filter). Tune the noise floor via .DotSettings at the repo root.
# ============================================================================
inspectcode:
name: "ReSharper InspectCode"
# Runs on Windows so .NET Framework reference assemblies (System,
# System.Xml.Linq, etc.) resolve natively for any net462 / net472 / net48
# projects the solution includes. On ubuntu-latest InspectCode fails
# with 60+ MSB3245 assembly-resolution errors on Framework-target
# projects unless we install mono — Windows has them out of the box.
# The build + test stages parallel this on Linux/Windows/macOS, so the
# Windows load here is not additive to the wall-clock the way an extra
# test stage would be.
runs-on: windows-latest
# All step scripts below use bash syntax (process substitution, [[ ]],
# etc.) — pin the default shell so it works uniformly on windows-latest
# (which defaults to pwsh) and not accidentally on any future runner
# swap.
defaults:
run:
shell: bash
needs: detect-projects
if: github.repository != 'Chris-Wolfgang/repo-template' && needs.detect-projects.outputs.has-projects == 'true'
timeout-minutes: 20
permissions:
contents: read
security-events: write # required for upload-sarif
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: refs/pull/${{ github.event.pull_request.number }}/head
persist-credentials: false
# Same defense-in-depth pattern as detect-projects and the test stages:
# jobs don't share workspaces under pull_request_target, so each build/
# analyzer job re-fetches protected config from main after checkout.
# Without this, a malicious PR could ship a permissive .editorconfig /
# BannedSymbols.txt / .DotSettings that would silence InspectCode
# findings on the PR's own code.
- name: Fetch trusted configuration files from main branch
# Skip for Dependabot — its package-version bumps to protected files are
# legitimate and should not be overwritten by main's older versions.
if: github.event.pull_request.user.login != 'dependabot[bot]'
run: |
echo "Fetching configuration files from main branch to prevent malicious overrides..."
git fetch origin main:main-branch
config_files=(
".editorconfig"
"Directory.Build.props"
"Directory.Build.targets"
"BannedSymbols.txt"
"*.globalconfig"
"*.ruleset"
".github/workflows/*.yml"
".github/workflows/*.yaml"
)
for config_file in "${config_files[@]}"; do
if [[ "$config_file" == *"*"* ]]; then
# See Stage 1 for the process-substitution rationale (avoids a
# subshell that would swallow `exit 1` on failed copies).
while read -r file; do
if [ -n "$file" ]; then
echo " ✓ Copying $file from main branch"
mkdir -p "$(dirname "$file")"
if ! git show "main-branch:$file" > "$file"; then
echo "::error::Failed to copy $file from main-branch — aborting to prevent silent fall-back to PR-supplied protected config."
exit 1
fi
fi
done < <(git ls-tree -r --name-only main-branch | { grep -E "${config_file//\*/.*}" || true; })
else
if git cat-file -e "main-branch:$config_file" 2>/dev/null; then
echo " ✓ Copying $config_file from main branch"
if ! git show "main-branch:$config_file" > "$config_file"; then
echo "::error::Failed to copy $config_file from main-branch — aborting to prevent silent fall-back to PR-supplied protected config."
exit 1
fi
else
echo " ℹ️ $config_file not found in main branch, skipping"
fi
fi
done
echo ""
echo "✅ Configuration files secured - using versions from main branch"
- name: Setup .NET
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
with:
dotnet-version: '10.0.x'
# Restore + build everything the solution knows about. On
# windows-latest .NET Framework 4.x reference assemblies are bundled,
# so net462 / net472 / net48 projects (e.g. examples/CSharp.DotNet462.Example)
# build natively alongside net5+ / netstandard / netcoreapp projects
# — no filter loop needed. InspectCode then reads the built outputs
# (with `--no-build`) for the whole solution.
- name: Restore and build
run: |
dotnet restore
dotnet build -c Release --no-restore
- name: Install JetBrains.ReSharper.GlobalTools
run: dotnet tool install -g JetBrains.ReSharper.GlobalTools
- name: Run InspectCode
run: |
# Find a solution to inspect. Prefer .slnx (new format) then .sln.
# InspectCode requires SOMETHING solution-shaped — fail loudly if
# neither exists.
#
# Uses direct glob iteration (not `ls | head`). Under the workflow's
# `-e -o pipefail` shell, on Git Bash for Windows, an
# `ls *.slnx 2>/dev/null | head -n1` where no `*.slnx` exists exits
# the whole step with code 2 (pipefail propagates ls's non-zero
# exit through the pipe) BEFORE the `if [ -z ]` fallback even runs.
# Direct globbing with `for f in *.slnx *.sln` sidesteps the pipe
# entirely — no ls, no pipefail interaction.
SLN=""
for candidate in *.slnx *.sln; do
if [ -f "$candidate" ]; then
SLN="$candidate"
break
fi
done
if [ -z "$SLN" ]; then
echo "::error::No .slnx or .sln found at repo root — InspectCode needs one to run."
exit 1
fi
echo "Inspecting: $SLN"
jb inspectcode "$SLN" \
--output=inspect.sarif \
--format=sarif \
--severity=WARNING \
--no-build
- name: Upload SARIF to Code Scanning
uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4
with:
sarif_file: inspect.sarif
- name: Gate on error-severity findings
# PowerShell (native on windows-latest) so we don't depend on jq
# being preinstalled. Parses the SARIF via ConvertFrom-Json and
# counts results at level=error. Warnings still upload (visible in
# Security → Code scanning) but don't gate merge — raise to `error`
# later when the noise floor is acceptable.
shell: pwsh
run: |
$sarif = Get-Content inspect.sarif -Raw | ConvertFrom-Json
$count = @(
foreach ($run in $sarif.runs) {
foreach ($result in $run.results) {
if ($result.level -eq 'error') { $result }
}
}
).Count
if ($count -gt 0) {
Write-Host "::error::$count InspectCode error-severity finding(s) — see Security → Code scanning"
exit 1
}
Write-Host "✅ No error-severity InspectCode findings."
# ============================================================================
# STAGE 1: Linux - .NET Core/5+ Tests with Coverage Gate
# ============================================================================
test-linux-core:
name: "Stage 1: Linux Tests (.NET 5.0-10.0) + Coverage Gate"
runs-on: ubuntu-latest
needs: detect-projects
if: github.repository != 'Chris-Wolfgang/repo-template' && needs.detect-projects.outputs.has-projects == 'true'
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: refs/pull/${{ github.event.pull_request.number }}/head
persist-credentials: false
- name: Fetch trusted configuration files from main branch
# Skip for Dependabot — its package-version bumps to protected files (e.g.
# Directory.Build.props) are legitimate and should not be overwritten by main's
# older versions. Dependabot's identity is GitHub-controlled and not spoofable.
if: github.event.pull_request.user.login != 'dependabot[bot]'
run: |
echo "Fetching configuration files from main branch to prevent malicious overrides..."
# Fetch the main branch
git fetch origin main:main-branch
# List of configuration files that should come from trusted main branch
config_files=(
".editorconfig"
"Directory.Build.props"
"Directory.Build.targets"
"BannedSymbols.txt"
"*.globalconfig"
"*.ruleset"
".github/workflows/*.yml"
".github/workflows/*.yaml"
)
# Copy each configuration file from main branch if it exists
for config_file in "${config_files[@]}"; do
# Handle glob patterns
if [[ "$config_file" == *"*"* ]]; then
# Find files matching the pattern in main branch.
# NOTE: use process substitution (`done < <(...)`) instead of a
# plain pipeline. A piped `while` runs in a subshell — an
# `exit 1` from inside would only kill the subshell, not the
# outer step, letting a failed copy silently fall back to the
# PR-supplied protected config. Process substitution runs the
# loop in the parent shell so exit actually terminates the job.
while read -r file; do
if [ -n "$file" ]; then
echo " ✓ Copying $file from main branch"
mkdir -p "$(dirname "$file")"
if ! git show "main-branch:$file" > "$file"; then
echo "::error::Failed to copy $file from main-branch — aborting to prevent silent fall-back to PR-supplied protected config."
exit 1
fi
fi
# Mask grep's exit 1 on zero matches with `|| true` — under
# `set -eo pipefail`, an empty match would otherwise fail the step,
# but a pattern like `*.ruleset` legitimately has no matches in
# repos that don't ship one. The empty stream is fine; the while
# loop simply doesn't iterate.
done < <(git ls-tree -r --name-only main-branch | { grep -E "${config_file//\*/.*}" || true; })
else
# Check if file exists in main branch
if git cat-file -e "main-branch:$config_file" 2>/dev/null; then
echo " ✓ Copying $config_file from main branch"
if ! git show "main-branch:$config_file" > "$config_file"; then
echo "::error::Failed to copy $config_file from main-branch — aborting to prevent silent fall-back to PR-supplied protected config."
exit 1
fi
else
echo " ℹ️ $config_file not found in main branch, skipping"
fi
fi
done
echo ""
echo "✅ Configuration files secured - using versions from main branch"
# Fix for .NET 5.0 on Ubuntu 22.04+ - install libssl1.1 from the focal-security
# repository so APT verifies the package via GPG instead of a plain wget download.
- name: Install OpenSSL 1.1 for .NET 5.0
run: |
# signed-by= points apt at the Canonical archive keyring that ships on all
# GitHub-hosted Ubuntu runners. It contains the same signing key Canonical
# uses across releases (focal, jammy, noble), so it can verify focal-security
# packages from a non-focal runner without disabling signature checking.
# Earlier iteration used [trusted=yes] (skipping verification) as a quick
# unblock; this restores end-to-end signature verification.
echo "deb [signed-by=/usr/share/keyrings/ubuntu-archive-keyring.gpg] https://security.ubuntu.com/ubuntu focal-security main" | sudo tee /etc/apt/sources.list.d/focal-security.list
sudo apt-get update -q
sudo apt-get install --yes libssl1.1
sudo rm /etc/apt/sources.list.d/focal-security.list
- name: Setup .NET
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
with:
dotnet-version: |
3.1.x
5.0.x
6.0.x
7.0.x
8.0.x
9.0.x
10.0.x
- name: Restore .NET workloads
# Some projects (MAUI / MauiHybrid / Android / iOS / WPF) declare workloads via
# their TFMs (e.g. net10.0-android). For workload-bearing repos this installs them
# before restore; for pure-library repos with no workload TFMs, skip entirely to
# avoid ~5-15s of network-dependent setup and an extra failure mode.
shell: bash
run: |
if find . -name '*.csproj' -type f -exec grep -lE 'net[0-9]+\.[0-9]+-(android|ios|maccatalyst|maui|tvos|tizen|browser)' {} \; | grep -q .; then
echo "Workload-bearing TFMs detected — running dotnet workload restore"
dotnet workload restore
else
echo "No workload-bearing TFMs in any csproj — skipping dotnet workload restore"
fi
- name: Restore and build (exclude .NET Framework-only projects)
run: |
echo "Finding .NET project files in repository (via find command)..."
# Filter out projects that ONLY target .NET Framework 4.x
# Multi-targeting projects (e.g., net8.0;net48) will be INCLUDED
projects=()
project_found=false
while IFS= read -r -d '' proj; do
project_found=true
# Check if project has any .NET 5+ target framework
# Look for: net5.0, net6.0, net7.0, net8.0, net9.0, net10.0, or netcoreapp, netstandard
# Normalize line endings to handle multi-line <TargetFramework> / <TargetFrameworks> elements
if tr -d '\n\r' < "$proj" | grep -qE '<TargetFramework[s]?>.*(net(5\.0|6\.0|7\.0|8\.0|9\.0|10\.0)|netcoreapp|netstandard)'; then
projects+=("$proj")
echo "✓ Including: $proj (has .NET 5+ or .NET Core target)"
else
echo "⊘ Excluding: $proj (Framework-only, incompatible with Linux)"
fi
done < <(find . -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -print0)
if [ "$project_found" = false ]; then
echo "❌ No .NET projects found."
echo "This should not occur as detect-projects already verified project existence."
exit 1
fi
if [ ${#projects[@]} -eq 0 ]; then
echo "❌ No compatible .NET projects found."
echo "All projects target only .NET Framework 4.x, which is incompatible with Linux."
exit 1
fi
echo ""
echo "=========================================="
echo "Projects to build:"
echo "=========================================="
printf '%s\n' "${projects[@]}"
echo ""
# Restore each project
echo "Restoring projects..."
for proj in "${projects[@]}"; do
echo "Restoring: $proj"
dotnet restore "$proj" || exit 1
done
echo ""
echo "Building projects..."
# Build each project, handling multi-targeting projects
# For multi-targeting projects, build only Linux-compatible frameworks (.NET 5.0+, .NET Core, .NET Standard)
for proj in "${projects[@]}"; do
echo "Building: $proj"
# Extract target frameworks via MSBuild property evaluation.
# This handles multi-line <TargetFrameworks> XML, conditional property groups,
# and TFMs inherited from Directory.Build.props — all of which break grep-based parsing.
# Falls back from <TargetFrameworks> (multiple) to <TargetFramework> (single).
tfm_raw=$(dotnet msbuild "$proj" -noLogo -getProperty:TargetFrameworks 2>/dev/null \
| grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFrameworks[=:][[:space:]]*//' | tr -d '[:space:]')
if [ -z "$tfm_raw" ]; then
tfm_raw=$(dotnet msbuild "$proj" -noLogo -getProperty:TargetFramework 2>/dev/null \
| grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFramework[=:][[:space:]]*//' | tr -d '[:space:]')
fi
frameworks=$(printf '%s' "$tfm_raw" | tr ';' '\n' | grep -E '^(net(5\.0|6\.0|7\.0|8\.0|9\.0|10\.0)|netcoreapp[0-9.]+|netstandard[0-9.]+)$' || true)
if [ -z "$frameworks" ]; then
echo "⚠️ No Linux-compatible frameworks found in $proj"
continue
fi
# Check if this is a multi-targeting project
framework_count=$(echo "$frameworks" | wc -l)
if [ "$framework_count" -eq 1 ]; then
# Single target framework - build normally
echo " Target framework: $frameworks"
dotnet build "$proj" --no-restore --configuration Release || exit 1
else
# Multi-targeting project - build each compatible framework separately
echo " Target frameworks (multi-targeting): $(echo "$frameworks" | tr '\n' ' ')"
while IFS= read -r fw; do
[ -z "$fw" ] && continue
echo " Building framework: $fw"
dotnet build "$proj" --no-restore --configuration Release --framework "$fw" || exit 1
done <<< "$frameworks"
fi
done
echo ""
echo "✅ All compatible projects built successfully"
- name: Run tests with coverage (.NET Core 5.0 - 10.0)
run: |
# Find all test projects (C#, VB.NET, F#).
# Gracefully skip if there is no ./tests directory (e.g. template-publishing
# repos or library repos in early development that have no tests yet).
# The downstream coverage steps already handle the no-coverage-files case.
# Fail loudly if the repo HAS src/ projects — the coverage gate
# exists to enforce test coverage on shipping code, so silently
# passing when tests are missing is the wrong default. Skip only
# for template-pack / in-dev repos with no source projects yet.
if [ ! -d ./tests ]; then
if find ./src -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -print -quit 2>/dev/null | grep -q .; then
echo "❌ ./tests directory is missing but ./src contains projects — refusing to silently skip the coverage gate."
exit 1
fi
echo "ℹ️ No ./tests directory and no ./src projects — skipping test stage (template-pack / in-dev shape)."
exit 0
fi
mapfile -d '' -t test_projects < <(find ./tests -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -print0)
if [ ${#test_projects[@]} -eq 0 ]; then
if find ./src -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -print -quit 2>/dev/null | grep -q .; then
echo "❌ No test projects under ./tests but ./src contains projects — refusing to silently skip the coverage gate."
exit 1
fi
echo "ℹ️ No test projects found under ./tests and no ./src projects — skipping test stage (template-pack / in-dev shape)."
exit 0
fi
echo "=========================================="
echo "Found test projects:"
echo "=========================================="
printf '%s\n' "${test_projects[@]}"
echo ""
for test_proj in "${test_projects[@]}"; do
echo "=========================================="
echo "Testing project: $test_proj"
echo "=========================================="
# Extract target frameworks via MSBuild property evaluation (handles multi-line XML
# and Directory.Build.props inheritance — both break grep-based parsing).
# Falls back from <TargetFrameworks> (multiple) to <TargetFramework> (single).
tfm_raw=$(dotnet msbuild "$test_proj" -noLogo -getProperty:TargetFrameworks 2>/dev/null \
| grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFrameworks[=:][[:space:]]*//' | tr -d '[:space:]')
if [ -z "$tfm_raw" ]; then
tfm_raw=$(dotnet msbuild "$test_proj" -noLogo -getProperty:TargetFramework 2>/dev/null \
| grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFramework[=:][[:space:]]*//' | tr -d '[:space:]')
fi
frameworks=$(printf '%s' "$tfm_raw" | tr ';' '\n' | grep -E '^(net(5\.0|6\.0|7\.0|8\.0|9\.0|10\.0)|netcoreapp3\.1)$' || true)
if [ -z "$frameworks" ]; then
echo "⊘ Skipping: No compatible .NET 5.0-10.0 target frameworks found"
echo ""
continue
fi
echo "Target frameworks: $(echo "$frameworks" | tr '\n' ' ')"
echo ""
# Test each framework that the project actually targets
while IFS= read -r fw; do
[ -z "$fw" ] && continue
echo "Testing framework: $fw"
dotnet test "$test_proj" \
--configuration Release \
--framework "$fw" \
--no-build --no-restore \
--collect:"XPlat Code Coverage" \
--settings coverlet.runsettings \
--results-directory "./TestResults" \
--logger "console;verbosity=minimal" || exit 1
done <<< "$frameworks"
echo ""
done
- name: Check for coverage files
id: check-coverage
run: |
if find TestResults -type f -name "coverage.cobertura.xml" 2>/dev/null | grep -q .; then
echo "has-coverage=true" >> "$GITHUB_OUTPUT"
echo "✅ Coverage files found"
else
echo "has-coverage=false" >> "$GITHUB_OUTPUT"
echo "ℹ️ No coverage files found - skipping coverage report generation"
fi
- name: Install ReportGenerator
if: steps.check-coverage.outputs.has-coverage == 'true'
run: dotnet tool install -g dotnet-reportgenerator-globaltool
- name: Generate coverage report
if: steps.check-coverage.outputs.has-coverage == 'true'
run: |
reportgenerator \
-reports:"TestResults/**/coverage.cobertura.xml" \
-targetdir:"CoverageReport" \
-reporttypes:"Html;TextSummary;MarkdownSummaryGithub;CsvSummary"
- name: Enforce 90% coverage threshold
if: steps.check-coverage.outputs.has-coverage == 'true'
run: |
if [ ! -f CoverageReport/Summary.txt ]; then
echo "❌ Coverage report not generated!"
exit 1
fi
echo "Coverage Summary:"
cat CoverageReport/Summary.txt
echo ""
failed_projects=""
threshold=${CODECOV_MINIMUM:-90}
matched_count=0
# IFS= is load-bearing: a bare `read -r` strips leading whitespace,
# which erases the indentation that distinguishes ReportGenerator's
# per-class rows from assembly rows — the ^[^ ] filter below then
# matches every class row and gates classes as if they were
# projects (surfaced on ETL-SqlBulkCopy's 0.7.0 release; see #157).
while IFS= read -r line; do
# Match lines with module names and percentages. The percent
# capture is the LAST %-suffixed number on the line, matching
# Stage 2's behavior — ReportGenerator Summary.txt rows often
# have line/branch/method columns and the overall figure is at
# end-of-line.
if echo "$line" | grep -qE '^[^ ].*[0-9]+(\.[0-9]+)?%$' && ! echo "$line" | grep -q '^Summary'; then
module=$(echo "$line" | awk '{print $1}')
# Floor the percent to int (matches Stage 2 pwsh's [int][math]::Floor)
# so we can use bash's integer -lt comparator below without
# erroring on decimals like "90.4".
percent=$(echo "$line" | awk '{print $NF}' | tr -d '%' | awk '{print int($1)}')
matched_count=$((matched_count + 1))
echo "Checking module: '$module' - Coverage: ${percent}%"
if [ "$percent" -lt "$threshold" ]; then
echo " ❌ FAIL: Below ${threshold}% threshold"
failed_projects="$failed_projects $module (${percent}%)"
else
echo " ✅ PASS: Meets ${threshold}% threshold"
fi
fi
done < CoverageReport/Summary.txt
# Fail loudly when 0 modules matched - the regex is wrong or
# Summary.txt format changed. Silently passing the gate when we
# couldn't parse coverage is worse than failing.
if [ "$matched_count" -eq 0 ]; then
echo "❌ Coverage parser matched 0 modules in Summary.txt - regex or report format is out of sync. Refusing to silently pass the gate."
exit 1
fi
if [ -n "$failed_projects" ]; then
echo ""
echo "=========================================="
echo "❌ COVERAGE GATE FAILED"
echo "=========================================="
echo "Projects below ${threshold}% coverage: $failed_projects"
echo ""
echo "Stage 1 failed. Windows, macOS, and .NET Framework tests will NOT run."
exit 1
else
echo ""
echo "=========================================="
echo "✅ COVERAGE GATE PASSED"
echo "=========================================="
echo "All projects meet ${threshold}% coverage threshold."
echo "Proceeding to Stage 2 (Windows and macOS tests)."
fi
- name: Upload Linux coverage results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: coverage-linux
path: |
TestResults/
CoverageReport/
- name: Upload build artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: build-output
path: |
src/**/bin/Release
tests/**/bin/Release
# ============================================================================
# STAGE 2: Windows - All .NET Tests (Gated by Stage 1)
# ============================================================================
test-windows:
name: "Stage 2: Windows Tests (.NET 5.0-10.0, Framework 4.6.2-4.8.1)"
runs-on: windows-latest
needs: [detect-projects, test-linux-core]
if: github.repository != 'Chris-Wolfgang/repo-template' && needs.detect-projects.outputs.has-projects == 'true'
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: refs/pull/${{ github.event.pull_request.number }}/head
persist-credentials: false
- name: Fetch trusted configuration files from main branch
# Skip for Dependabot — its package-version bumps to protected files (e.g.
# Directory.Build.props) are legitimate and should not be overwritten by main's
# older versions. Dependabot's identity is GitHub-controlled and not spoofable.
if: github.event.pull_request.user.login != 'dependabot[bot]'
shell: pwsh
run: |
Write-Host "Fetching configuration files from main branch to prevent malicious overrides..."
# Fetch the main branch
git fetch origin main:main-branch
# List of configuration files that should come from trusted main branch
$configFiles = @(
".editorconfig",
"Directory.Build.props",
"Directory.Build.targets",
"BannedSymbols.txt"
)
# Copy each configuration file from main branch if it exists
foreach ($configFile in $configFiles) {
# Check if file exists in main branch
$exists = git cat-file -e "main-branch:$configFile" 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host " ✓ Copying $configFile from main branch"
git show "main-branch:$configFile" | Out-File -FilePath $configFile -Encoding UTF8NoBOM
} else {
Write-Host " ℹ️ $configFile not found in main branch, skipping"
}
}
# Handle glob patterns for .globalconfig, .ruleset, and workflow files
$globPatterns = @("*.globalconfig", "*.ruleset", ".github/workflows/*.yml", ".github/workflows/*.yaml")
foreach ($pattern in $globPatterns) {
$files = git ls-tree -r --name-only main-branch | Select-String -Pattern $pattern.Replace("*", ".*")
foreach ($file in $files) {
if ($file) {
Write-Host " ✓ Copying $file from main branch"
$dir = Split-Path -Parent $file
if ($dir) { New-Item -ItemType Directory -Force -Path $dir | Out-Null }
git show "main-branch:$file" | Out-File -FilePath $file -Encoding UTF8NoBOM
}
}
}
Write-Host ""
Write-Host "✅ Configuration files secured - using versions from main branch"
- name: Setup .NET
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
with:
dotnet-version: |
3.1.x
5.0.x
6.0.x
7.0.x
8.0.x
9.0.x
10.0.x
- name: Restore .NET workloads
# Some projects (MAUI / MauiHybrid / Android / iOS / WPF) declare workloads via
# their TFMs (e.g. net10.0-android). For workload-bearing repos this installs them
# before restore; for pure-library repos with no workload TFMs, skip entirely to
# avoid ~5-15s of network-dependent setup and an extra failure mode.
shell: bash
run: |
if find . -name '*.csproj' -type f -exec grep -lE 'net[0-9]+\.[0-9]+-(android|ios|maccatalyst|maui|tvos|tizen|browser)' {} \; | grep -q .; then
echo "Workload-bearing TFMs detected — running dotnet workload restore"
dotnet workload restore
else
echo "No workload-bearing TFMs in any csproj — skipping dotnet workload restore"
fi
- name: Restore dependencies
run: dotnet restore
- name: Build solution
run: dotnet build --no-restore --configuration Release
- name: Run all .NET tests (.NET 5.0-10.0 and Framework 4.6.2-4.8.1)
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
# Gracefully skip if there is no ./tests directory (e.g. template-publishing
# repos or library repos in early development that have no tests yet).
# The coverage gate exists to enforce test coverage on shipping
# code. If ./src has projects but ./tests doesn't, fail loudly
# instead of silently passing the gate. Skip only for template-
# pack / in-dev repos that have no source projects yet.
$srcHasProjects = @(Get-ChildItem -Path './src' -Recurse -File -Include '*.csproj','*.vbproj','*.fsproj' -ErrorAction SilentlyContinue).Count -gt 0
if (-not (Test-Path -Path './tests' -PathType Container)) {
if ($srcHasProjects) {
Write-Error "❌ ./tests directory is missing but ./src contains projects — refusing to silently skip the coverage gate."
exit 1
}
Write-Host "ℹ️ No ./tests directory and no ./src projects — skipping test stage (template-pack / in-dev shape)."
exit 0
}
$testProjects = @(Get-ChildItem -Path './tests/*' -Recurse -File -Include '*.csproj','*.vbproj','*.fsproj')
if (@($testProjects).Count -eq 0) {
if ($srcHasProjects) {
Write-Error "❌ No test projects under ./tests but ./src contains projects — refusing to silently skip the coverage gate."
exit 1
}
Write-Host "ℹ️ No test projects found under ./tests and no ./src projects — skipping test stage (template-pack / in-dev shape)."
exit 0
}
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host "Found test projects:" -ForegroundColor Cyan
Write-Host "==========================================" -ForegroundColor Cyan
$testProjects | ForEach-Object { Write-Host $_.FullName -ForegroundColor White }
Write-Host ""
foreach ($testProj in $testProjects) {
Write-Host "==========================================" -ForegroundColor Cyan
Write-Host "Testing project: $($testProj.FullName)" -ForegroundColor Cyan
Write-Host "==========================================" -ForegroundColor Cyan
# Extract target frameworks from the project file
# Support both <TargetFramework> (single) and <TargetFrameworks> (multiple)
$content = Get-Content $testProj.FullName -Raw
$tfmMatch = [regex]::Match($content, '<TargetFramework[s]?>([^<]+)</TargetFramework[s]?>')
if (-not $tfmMatch.Success) {
Write-Host "⊘ Skipping: No target frameworks found" -ForegroundColor Yellow
Write-Host ""
continue
}
# Split by semicolon for multi-targeting projects
$frameworks = $tfmMatch.Groups[1].Value -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ -match '^net(5\.0|6\.0|7\.0|8\.0|9\.0|10\.0|462|47|471|472|48|481|coreapp3\.1)$' }
if ($frameworks.Count -eq 0) {
Write-Host "⊘ Skipping: No compatible .NET 5.0-10.0 or Framework 4.6.2-4.8.1 target frameworks found" -ForegroundColor Yellow
Write-Host ""
continue
}
Write-Host "Target frameworks: $($frameworks -join ', ')" -ForegroundColor White
Write-Host ""
# Test each framework; collect coverage only for .NET 5.0+ TFMs.
# netcoreapp3.1 and net4x are tested but excluded from coverage:
# netcoreapp3.1 has no matching test TFM on Linux (Stage 1) so its numbers
# would not be comparable; net4x cannot use the XPlat collector on Windows.
foreach ($fw in $frameworks) {
Write-Host "Testing framework: $fw" -ForegroundColor Yellow
if ($fw -match '^net([5-9]|[1-9][0-9]+)\.') {
dotnet test $testProj.FullName `
--configuration Release `
--framework $fw `
--no-build --no-restore `
--collect:"XPlat Code Coverage" `
--settings coverlet.runsettings `
--results-directory "./TestResults" `
--logger "console;verbosity=normal"
} else {
dotnet test $testProj.FullName `
--configuration Release `
--framework $fw `
--no-build --no-restore `
--logger "console;verbosity=normal"
}
if ($LASTEXITCODE -ne 0) {
Write-Error "Tests failed for $fw in $($testProj.Name)"
exit 1
}
}
Write-Host ""
}
- name: Check for coverage files
id: check-coverage
run: |
if (Get-ChildItem -Path TestResults -Recurse -Filter coverage.cobertura.xml -ErrorAction SilentlyContinue) {
echo "has-coverage=true" >> $env:GITHUB_OUTPUT
Write-Host "✅ Coverage files found"
} else {
echo "has-coverage=false" >> $env:GITHUB_OUTPUT
Write-Host "ℹ️ No coverage files found - skipping coverage report generation"
}
shell: pwsh
- name: Install ReportGenerator
if: steps.check-coverage.outputs.has-coverage == 'true'
run: dotnet tool install -g dotnet-reportgenerator-globaltool
- name: Generate coverage report
if: steps.check-coverage.outputs.has-coverage == 'true'
shell: pwsh
run: |
reportgenerator `
-reports:"TestResults/**/coverage.cobertura.xml" `
-targetdir:"CoverageReport" `
-reporttypes:"Html;TextSummary;MarkdownSummaryGithub;CsvSummary"
- name: Enforce 90% coverage threshold
if: steps.check-coverage.outputs.has-coverage == 'true'
shell: pwsh
run: |
if (-not (Test-Path "CoverageReport/Summary.txt")) {
Write-Error "❌ Coverage report not generated!"
exit 1
}
Write-Host "Coverage Summary:"
Get-Content "CoverageReport/Summary.txt"
Write-Host ""
$threshold = if ($env:CODECOV_MINIMUM) { [int]$env:CODECOV_MINIMUM } else { 90 }
$failedProjects = @()
$matchedCount = 0
foreach ($line in (Get-Content "CoverageReport/Summary.txt")) {
# Only consider top-level assembly rows: non-space first char,
# then anything, then whitespace + the final percent at EOL.
# Matches Stage 1's `^[^ ].*[0-9]+(\.[0-9]+)?%$` filter (which
# uses awk $NF for the percent — robust to extra columns like
# line/branch/method that ReportGenerator can emit on the same
# row).
#
# Bug previously here: a `.*` between the module name and the
# trailing `(\d+)%` was greedy and could eat all but the last
# digit of the percent — turning "100" into "0" and failing
# the gate on actually-100%-covered modules. Two changes:
# - Anchor on `^(\S+)` so indented sub-class rows are skipped
# (their parent assembly row carries the same number, so
# nothing is lost — and Stage 1 ignores them too).
# - Require whitespace immediately before the final `\d+%`
# (`\s(\d+...)%\s*$`). This still allows intermediate
# columns between the module name and the final percent
# (the `.*` consumes them), but `.*` can't terminate
# mid-digit-run — the regex engine MUST place `\s` before
# the digits, which forces the last %-suffixed number on
# the line to be captured intact.
if ($line -match '^(\S+).*\s(\d+(?:\.\d+)?)%\s*$' -and $line -notmatch '^Summary') {
$module = $Matches[1]
$percent = [int][math]::Floor([double]$Matches[2])
$matchedCount++
Write-Host "Checking module: '$module' - Coverage: ${percent}%"
if ($percent -lt $threshold) {
Write-Host " ❌ FAIL: Below ${threshold}% threshold" -ForegroundColor Red
$failedProjects += "$module (${percent}%)"
} else {
Write-Host " ✅ PASS: Meets ${threshold}% threshold" -ForegroundColor Green
}
}
}
# Fail loudly when 0 modules matched — the regex is wrong or
# Summary.txt format changed. Silently passing the gate when we
# couldn't read coverage is worse than failing.
if ($matchedCount -eq 0) {
Write-Error "❌ Coverage parser matched 0 modules in Summary.txt — regex or report format is out of sync. Refusing to silently pass the gate."
exit 1
}
if ($failedProjects.Count -gt 0) {
Write-Host ""
Write-Host "==========================================" -ForegroundColor Red
Write-Host "❌ COVERAGE GATE FAILED" -ForegroundColor Red
Write-Host "==========================================" -ForegroundColor Red
Write-Host "Projects below ${threshold}% coverage: $($failedProjects -join ', ')" -ForegroundColor Red
Write-Host ""
Write-Host "Stage 2 failed. macOS tests will NOT run."
exit 1
}
Write-Host ""
Write-Host "==========================================" -ForegroundColor Green
Write-Host "✅ COVERAGE GATE PASSED" -ForegroundColor Green
Write-Host "==========================================" -ForegroundColor Green
Write-Host "All projects meet ${threshold}% coverage threshold."
Write-Host "Proceeding to Stage 3 (macOS tests)."
- name: Upload Windows coverage results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: coverage-windows
path: |
TestResults/
CoverageReport/
# ============================================================================
# STAGE 3: macOS Tests (Gated by Stage 2)
# ============================================================================
test-macos-core:
name: "Stage 3: macOS Tests (.NET 6.0-10.0)"
runs-on: macos-latest
needs: [detect-projects, test-windows]
if: github.repository != 'Chris-Wolfgang/repo-template' && needs.detect-projects.outputs.has-projects == 'true'
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: refs/pull/${{ github.event.pull_request.number }}/head
persist-credentials: false
- name: Fetch trusted configuration files from main branch
# Skip for Dependabot — its package-version bumps to protected files (e.g.
# Directory.Build.props) are legitimate and should not be overwritten by main's
# older versions. Dependabot's identity is GitHub-controlled and not spoofable.
if: github.event.pull_request.user.login != 'dependabot[bot]'
run: |
echo "Fetching configuration files from main branch to prevent malicious overrides..."
# Fetch the main branch
git fetch origin main:main-branch
# List of configuration files that should come from trusted main branch
config_files=(
".editorconfig"
"Directory.Build.props"
"Directory.Build.targets"
"BannedSymbols.txt"
"*.globalconfig"
"*.ruleset"
".github/workflows/*.yml"
".github/workflows/*.yaml"
)
# Copy each configuration file from main branch if it exists
for config_file in "${config_files[@]}"; do
# Handle glob patterns
if [[ "$config_file" == *"*"* ]]; then
# Find files matching the pattern in main branch.
# NOTE: use process substitution (`done < <(...)`) instead of a
# plain pipeline. A piped `while` runs in a subshell — an
# `exit 1` from inside would only kill the subshell, not the
# outer step, letting a failed copy silently fall back to the
# PR-supplied protected config. Process substitution runs the
# loop in the parent shell so exit actually terminates the job.
while read -r file; do
if [ -n "$file" ]; then
echo " ✓ Copying $file from main branch"
mkdir -p "$(dirname "$file")"
if ! git show "main-branch:$file" > "$file"; then
echo "::error::Failed to copy $file from main-branch — aborting to prevent silent fall-back to PR-supplied protected config."
exit 1
fi
fi
# Mask grep's exit 1 on zero matches with `|| true` — under
# `set -eo pipefail`, an empty match would otherwise fail the step,
# but a pattern like `*.ruleset` legitimately has no matches in
# repos that don't ship one. The empty stream is fine; the while
# loop simply doesn't iterate.
done < <(git ls-tree -r --name-only main-branch | { grep -E "${config_file//\*/.*}" || true; })
else
# Check if file exists in main branch
if git cat-file -e "main-branch:$config_file" 2>/dev/null; then
echo " ✓ Copying $config_file from main branch"
if ! git show "main-branch:$config_file" > "$config_file"; then
echo "::error::Failed to copy $config_file from main-branch — aborting to prevent silent fall-back to PR-supplied protected config."
exit 1
fi
else
echo " ℹ️ $config_file not found in main branch, skipping"
fi
fi
done
echo ""
echo "✅ Configuration files secured - using versions from main branch"
- name: Setup .NET
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
with:
dotnet-version: |
6.0.x
7.0.x
8.0.x
9.0.x
10.0.x
- name: Restore .NET workloads
# Some projects (MAUI / MauiHybrid / Android / iOS / WPF) declare workloads via
# their TFMs (e.g. net10.0-android). For workload-bearing repos this installs them
# before restore; for pure-library repos with no workload TFMs, skip entirely to
# avoid ~5-15s of network-dependent setup and an extra failure mode.
shell: bash
run: |
if find . -name '*.csproj' -type f -exec grep -lE 'net[0-9]+\.[0-9]+-(android|ios|maccatalyst|maui|tvos|tizen|browser)' {} \; | grep -q .; then
echo "Workload-bearing TFMs detected — running dotnet workload restore"
dotnet workload restore
else
echo "No workload-bearing TFMs in any csproj — skipping dotnet workload restore"
fi
- name: Restore and build (exclude .NET Framework-only projects)
run: |
echo "Enumerating tracked .NET project files (git ls-files)..."
# Filter out projects that ONLY target .NET Framework 4.x
# Multi-targeting projects (e.g., net8.0;net48) will be INCLUDED
projects=()
project_found=false
while IFS= read -r -d '' proj; do
project_found=true
# Check if project has any .NET 6+ target framework (macOS ARM64 compatible)
# Look for: net6.0, net7.0, net8.0, net9.0, net10.0
# Normalize newlines to spaces so multi-line <TargetFrameworks> elements are matched correctly
if tr $'\n' ' ' < "$proj" | grep -qE '<TargetFramework[s]?>[^<]*net(6\.0|7\.0|8\.0|9\.0|10\.0)'; then
projects+=("$proj")
echo "✓ Including: $proj (has .NET 6+ target)"
else
echo "⊘ Excluding: $proj (no .NET 6+ target, incompatible with macOS ARM64)"
fi
done < <(git ls-files -z -- '*.csproj' '*.vbproj' '*.fsproj')
if [ "$project_found" = false ]; then
echo "❌ No .NET projects found."
echo "This should not occur as detect-projects already verified project existence."
exit 1
fi
if [ ${#projects[@]} -eq 0 ]; then
echo "❌ No compatible .NET projects found."
echo "All projects lack .NET 6+ targets, which are required for macOS ARM64."
exit 1
fi
echo ""
echo "=========================================="
echo "Projects to build (excluding .NET Framework-only projects):"
echo "=========================================="
printf '%s\n' "${projects[@]}"
echo ""
# Restore each project
echo "Restoring projects..."
for proj in "${projects[@]}"; do
echo "Restoring: $proj"
dotnet restore "$proj" || exit 1
done
echo ""
echo "Building projects..."
# Build each project, handling multi-targeting projects
# For multi-targeting projects, build only macOS ARM64-compatible frameworks (net6.0-10.0)
for proj in "${projects[@]}"; do
echo "Building: $proj"
# Extract target frameworks via MSBuild property evaluation (handles multi-line XML
# and Directory.Build.props inheritance). Filter to .NET 6+ for macOS ARM64 compatibility.
# Falls back from <TargetFrameworks> (multiple) to <TargetFramework> (single).
tfm_raw=$(dotnet msbuild "$proj" -noLogo -getProperty:TargetFrameworks 2>/dev/null \
| grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFrameworks[=:][[:space:]]*//' | tr -d '[:space:]')
if [ -z "$tfm_raw" ]; then
tfm_raw=$(dotnet msbuild "$proj" -noLogo -getProperty:TargetFramework 2>/dev/null \
| grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFramework[=:][[:space:]]*//' | tr -d '[:space:]')
fi
frameworks=$(printf '%s' "$tfm_raw" | tr ';' '\n' | grep -E '^net(6\.0|7\.0|8\.0|9\.0|10\.0)$' || true)
if [ -z "$frameworks" ]; then
echo "⚠️ No macOS ARM64-compatible frameworks found in $proj"
continue
fi
# Check if this is a multi-targeting project
framework_count=$(echo "$frameworks" | wc -l)
if [ "$framework_count" -eq 1 ]; then
# Single target framework - build normally
echo " Target framework: $frameworks"
dotnet build "$proj" --no-restore --configuration Release || exit 1
else
# Multi-targeting project - build each compatible framework separately
echo " Target frameworks (multi-targeting): $(echo "$frameworks" | tr '\n' ' ')"
while IFS= read -r fw; do
[ -z "$fw" ] && continue
echo " Building framework: $fw"
dotnet build "$proj" --no-restore --configuration Release --framework "$fw" || exit 1
done <<< "$frameworks"
fi
done
echo ""
echo "✅ All compatible projects built successfully"
- name: Run tests (.NET 6.0 - 10.0 only - ARM64 compatible)
run: |
# Find all test projects (C#, VB.NET, F#).
# Gracefully skip if there is no ./tests directory (e.g. template-publishing
# repos or library repos in early development that have no tests yet).
# Fail loudly if the repo HAS src/ projects — the coverage gate
# exists to enforce test coverage on shipping code, so silently
# passing when tests are missing is the wrong default. Skip only
# for template-pack / in-dev repos with no source projects yet.
if [ ! -d ./tests ]; then
if find ./src -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -print -quit 2>/dev/null | grep -q .; then
echo "❌ ./tests directory is missing but ./src contains projects — refusing to silently skip the coverage gate."
exit 1
fi
echo "ℹ️ No ./tests directory and no ./src projects — skipping test stage (template-pack / in-dev shape)."
exit 0
fi
test_projects=()
while IFS= read -r -d '' file; do
test_projects+=("$file")
done < <(find ./tests -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -print0)
if [ ${#test_projects[@]} -eq 0 ]; then
if find ./src -type f \( -name "*.csproj" -o -name "*.vbproj" -o -name "*.fsproj" \) -print -quit 2>/dev/null | grep -q .; then
echo "❌ No test projects under ./tests but ./src contains projects — refusing to silently skip the coverage gate."
exit 1
fi
echo "ℹ️ No test projects found under ./tests and no ./src projects — skipping test stage (template-pack / in-dev shape)."
exit 0
fi
echo "=========================================="
echo "Found test projects:"
echo "=========================================="
printf '%s\n' "${test_projects[@]}"
echo ""
for test_proj in "${test_projects[@]}"; do
echo "=========================================="
echo "Testing project: $test_proj"
echo "=========================================="
# Extract target frameworks via MSBuild property evaluation (handles multi-line XML
# and Directory.Build.props inheritance). Filter to .NET 6+ for macOS ARM64 compatibility.
# Falls back from <TargetFrameworks> (multiple) to <TargetFramework> (single).
tfm_raw=$(dotnet msbuild "$test_proj" -noLogo -getProperty:TargetFrameworks 2>/dev/null \
| grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFrameworks[=:][[:space:]]*//' | tr -d '[:space:]')
if [ -z "$tfm_raw" ]; then
tfm_raw=$(dotnet msbuild "$test_proj" -noLogo -getProperty:TargetFramework 2>/dev/null \
| grep -v '^[[:space:]]*$' | tail -n1 | sed 's/^TargetFramework[=:][[:space:]]*//' | tr -d '[:space:]')
fi
frameworks=$(printf '%s' "$tfm_raw" | tr ';' '\n' | grep -E '^net(6\.0|7\.0|8\.0|9\.0|10\.0)$' || true)
if [ -z "$frameworks" ]; then
echo "⊘ Skipping: No compatible .NET 6.0-10.0 target frameworks found (ARM64 required)"
echo ""
continue
fi
echo "Target frameworks: $(echo "$frameworks" | tr '\n' ' ')"
echo ""
# Test each framework that the project actually targets
# All frameworks here are net6.0+ so all get coverage
while IFS= read -r fw; do
[ -z "$fw" ] && continue
echo "Testing framework: $fw"
dotnet test "$test_proj" \
--configuration Release \
--framework "$fw" \
--no-build --no-restore \
--collect:"XPlat Code Coverage" \
--settings coverlet.runsettings \
--results-directory "./TestResults" \
--logger "console;verbosity=normal" || exit 1
done <<< "$frameworks"
echo ""
done
- name: Install ReportGenerator
run: dotnet tool install -g dotnet-reportgenerator-globaltool
- name: Generate coverage report
run: |
if find ./TestResults -name "coverage.cobertura.xml" -print -quit 2>/dev/null | grep -q .; then
reportgenerator \
-reports:"TestResults/**/coverage.cobertura.xml" \
-targetdir:"CoverageReport" \
-reporttypes:"Html;TextSummary;MarkdownSummaryGithub;CsvSummary"
else
echo "ℹ️ No coverage files found - skipping report generation"
fi
- name: Enforce 90% coverage threshold
run: |
# If no cobertura files were produced (no tests, all test projects
# skipped, etc.), the preceding step explicitly skipped report
# generation. Mirror that here — gating only when coverage was
# actually collected — instead of failing with "Coverage report
# not generated!" on jobs that legitimately had nothing to cover.
if ! find ./TestResults -name "coverage.cobertura.xml" -print -quit 2>/dev/null | grep -q .; then
echo "ℹ️ No coverage files produced — skipping coverage gate (consistent with the prior 'skipping report generation' notice)."
exit 0
fi
if [ ! -f "CoverageReport/Summary.txt" ]; then
echo "❌ Coverage files exist but Summary.txt is missing — ReportGenerator failed."
exit 1
fi
echo "Coverage Summary:"
cat CoverageReport/Summary.txt
echo ""
THRESHOLD=${CODECOV_MINIMUM:-90}
FAILED=0
while IFS= read -r line; do
if echo "$line" | grep -qE '^[^ ]+.*[0-9]+%$' && ! echo "$line" | grep -q '^Summary'; then
MODULE=$(echo "$line" | awk '{print $1}')
PERCENT=$(echo "$line" | grep -oE '[0-9]+(\.[0-9]+)?%' | tail -1 | grep -oE '^[0-9]+')
echo "Checking module: '$MODULE' - Coverage: ${PERCENT}%"
if [ "$PERCENT" -lt "$THRESHOLD" ]; then
echo " ❌ FAIL: Below ${THRESHOLD}% threshold"
FAILED=1
else
echo " ✅ PASS: Meets ${THRESHOLD}% threshold"
fi
fi
done < CoverageReport/Summary.txt
if [ "$FAILED" -ne 0 ]; then
echo ""
echo "=========================================="
echo "❌ COVERAGE GATE FAILED"
echo "=========================================="
echo "One or more modules are below ${THRESHOLD}% coverage."
echo "Stage 3 failed."
exit 1
fi
echo ""
echo "=========================================="
echo "✅ COVERAGE GATE PASSED"
echo "=========================================="
echo "All modules meet ${THRESHOLD}% coverage threshold."
- name: Upload macOS coverage results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: coverage-macos
path: |
TestResults/
CoverageReport/
- name: Display macOS architecture info
if: always()
run: |
echo ""
echo "=========================================="
echo "ℹ️ macOS Testing Notes"
echo "=========================================="
echo "Architecture: $(uname -m)"
echo ""
echo "Skipped frameworks (no ARM64 support):"
echo " - .NET 5.0 ❌"
echo ""
echo "Tested frameworks (ARM64 compatible):"
echo " - .NET 6.0 ✅"
echo " - .NET 7.0 ✅"
echo " - .NET 8.0 ✅"
echo " - .NET 9.0 ✅"
echo " - .NET 10.0 ✅"
echo ""
echo ".NET Core 5.0 are tested on Linux and Windows"
echo ""
- name: Summarize pipeline result
run: |
echo "=========================================="
echo "✅ ALL STAGES PASSED"
echo "=========================================="
echo "Stage 1: Linux tests + 90% coverage ✅"
echo "Stage 2: Windows .NET Core & .NET Framework tests ✅"
echo "Stage 3: macOS tests ✅"
echo ""
echo "PR is ready to merge! 🎉"
# ============================================================================
# Security Scan (Runs in parallel, independently of .NET jobs)
# ============================================================================
security-scan:
name: "Security Scan (DevSkim)"
runs-on: ubuntu-latest
if: github.repository != 'Chris-Wolfgang/repo-template'
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: refs/pull/${{ github.event.pull_request.number }}/head
persist-credentials: false
- name: Fetch trusted configuration files from main branch
# Skip for Dependabot — its package-version bumps to protected files (e.g.
# Directory.Build.props) are legitimate and should not be overwritten by main's
# older versions. Dependabot's identity is GitHub-controlled and not spoofable.
if: github.event.pull_request.user.login != 'dependabot[bot]'
run: |
echo "Fetching configuration files from main branch to prevent malicious overrides..."
# Fetch the main branch
git fetch origin main:main-branch
# List of configuration files that should come from trusted main branch
config_files=(
".editorconfig"
"Directory.Build.props"
"Directory.Build.targets"
"BannedSymbols.txt"
"*.globalconfig"
"*.ruleset"
".github/workflows/*.yml"
".github/workflows/*.yaml"
)
# Copy each configuration file from main branch if it exists
for config_file in "${config_files[@]}"; do
# Handle glob patterns
if [[ "$config_file" == *"*"* ]]; then
# Find files matching the pattern in main branch.
# NOTE: use process substitution (`done < <(...)`) instead of a
# plain pipeline. A piped `while` runs in a subshell — an
# `exit 1` from inside would only kill the subshell, not the
# outer step, letting a failed copy silently fall back to the
# PR-supplied protected config. Process substitution runs the
# loop in the parent shell so exit actually terminates the job.
while read -r file; do
if [ -n "$file" ]; then
echo " ✓ Copying $file from main branch"
mkdir -p "$(dirname "$file")"
if ! git show "main-branch:$file" > "$file"; then
echo "::error::Failed to copy $file from main-branch — aborting to prevent silent fall-back to PR-supplied protected config."
exit 1
fi
fi
# Mask grep's exit 1 on zero matches with `|| true` — under
# `set -eo pipefail`, an empty match would otherwise fail the step,
# but a pattern like `*.ruleset` legitimately has no matches in
# repos that don't ship one. The empty stream is fine; the while
# loop simply doesn't iterate.
done < <(git ls-tree -r --name-only main-branch | { grep -E "${config_file//\*/.*}" || true; })
else
# Check if file exists in main branch
if git cat-file -e "main-branch:$config_file" 2>/dev/null; then
echo " ✓ Copying $config_file from main branch"
if ! git show "main-branch:$config_file" > "$config_file"; then
echo "::error::Failed to copy $config_file from main-branch — aborting to prevent silent fall-back to PR-supplied protected config."
exit 1
fi
else
echo " ℹ️ $config_file not found in main branch, skipping"
fi
fi
done
echo ""
echo "✅ Configuration files secured - using versions from main branch"
- name: Install DevSkim CLI
run: dotnet tool install --global Microsoft.CST.DevSkim.CLI
- name: Run DevSkim security scan
run: |
devskim analyze \
--source-code . \
--file-format text \
--output-file devskim-results.txt \
--ignore-rule-ids DS176209 \
--ignore-globs "**/api/**,**/CoverageReport/**,**/TestResults/**"
- name: Display security scan results
if: always()
run: |
if [ -f devskim-results.txt ]; then
echo "=========================================="
echo "DevSkim Security Scan Results"
echo "=========================================="
cat devskim-results.txt
echo ""
if grep -qi "error\|critical\|high" devskim-results.txt; then
echo "❌ Security issues detected - review required"
exit 1
else
echo "✅ No critical security issues found"
fi
else
echo "✅ No security issues found"
fi
- name: Upload security scan results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: devskim-results
path: devskim-results.txt
if-no-files-found: warn