Skip to content

Commit 524ee4e

Browse files
committed
feat: CI efficiency, Shouldly assertions, docs SEO, quality targets, interactive init
- Add paths-ignore for docs/md changes and cancel-in-progress for PRs - Replace xUnit Assert with Shouldly (Apache-2.0) for fluent test assertions - Enable VitePress sitemap generation and add Open Graph / Twitter meta tags - Add optional Benchmark and MutationTest NUKE targets - Rewrite init.ps1/init.sh as interactive wizards with confirmation preview Made-with: Cursor
1 parent d4b9361 commit 524ee4e

File tree

12 files changed

+621
-5
lines changed

12 files changed

+621
-5
lines changed

.github/workflows/ci.yml

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,22 @@ name: CI and Release
33
on:
44
push:
55
branches: [main]
6+
paths-ignore:
7+
- 'docs/**'
8+
- '*.md'
9+
- '.github/ISSUE_TEMPLATE/**'
10+
- '.github/PULL_REQUEST_TEMPLATE.md'
11+
- '.github/FUNDING.yml'
12+
- 'LICENSE'
613
pull_request:
714
branches: [main]
15+
paths-ignore:
16+
- 'docs/**'
17+
- '*.md'
18+
- '.github/ISSUE_TEMPLATE/**'
19+
- '.github/PULL_REQUEST_TEMPLATE.md'
20+
- '.github/FUNDING.yml'
21+
- 'LICENSE'
822
workflow_dispatch:
923
inputs:
1024
prerelease_suffix:
@@ -18,7 +32,7 @@ permissions:
1832

1933
concurrency:
2034
group: ci-${{ github.ref }}
21-
cancel-in-progress: false
35+
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
2236

2337
env:
2438
DOTNET_NOLOGO: true

.nuke/build.schema.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,13 @@
2424
"ExecutableTarget": {
2525
"type": "string",
2626
"enum": [
27+
"Benchmark",
2728
"Build",
2829
"Clean",
2930
"CoverageReport",
3031
"Format",
3132
"GenerateReleaseManifest",
33+
"MutationTest",
3234
"Pack",
3335
"PackageApp",
3436
"Publish",
@@ -111,6 +113,10 @@
111113
"allOf": [
112114
{
113115
"properties": {
116+
"BenchmarkFilter": {
117+
"type": "string",
118+
"description": "Filter expression for BenchmarkDotNet (e.g., '*Calculator*')"
119+
},
114120
"BranchCoverageThreshold": {
115121
"type": "integer",
116122
"description": "Minimum branch coverage percentage (0-100). CoverageReport fails if below this threshold",

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
3232
To publish an RC: trigger workflow_dispatch with prerelease_suffix=rc.1 -->
3333
<PropertyGroup>
34-
<VersionPrefix>0.2.4</VersionPrefix>
34+
<VersionPrefix>0.2.5</VersionPrefix>
3535
<FileVersion Condition="'$(BuildNumber)' != ''">$(VersionPrefix).$(BuildNumber)</FileVersion>
3636
</PropertyGroup>
3737

Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
<!-- Analyzers -->
1212
<PackageVersion Include="Microsoft.CodeAnalysis.PublicApiAnalyzers" Version="3.3.4" />
1313
<!-- Testing -->
14+
<PackageVersion Include="Shouldly" Version="4.3.0" />
1415
<PackageVersion Include="coverlet.collector" Version="8.0.0" />
1516
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
1617
<PackageVersion Include="xunit" Version="2.9.3" />
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
using Nuke.Common;
2+
using Nuke.Common.IO;
3+
using static Nuke.Common.Tools.DotNet.DotNetTasks;
4+
5+
partial class BuildTask
6+
{
7+
AbsolutePath BenchmarkResultsDirectory => ArtifactsDirectory / "benchmark-results";
8+
9+
[Parameter("Filter expression for BenchmarkDotNet (e.g., '*Calculator*')")]
10+
readonly string BenchmarkFilter = "*";
11+
12+
Target Benchmark => _ => _
13+
.DependsOn(Restore)
14+
.Description("Run BenchmarkDotNet benchmarks. Requires a project with BenchmarkDotNet references.")
15+
.Executes(() =>
16+
{
17+
BenchmarkResultsDirectory.CreateOrCleanDirectory();
18+
19+
var benchProjects = RootDirectory.GlobFiles("benchmarks/**/*.csproj");
20+
if (benchProjects.Count == 0)
21+
{
22+
Serilog.Log.Warning("No benchmark projects found under benchmarks/. Skipping.");
23+
return;
24+
}
25+
26+
foreach (var project in benchProjects)
27+
{
28+
DotNet(
29+
$"run --project {project} -c Release " +
30+
$"-- --filter {BenchmarkFilter} " +
31+
$"--artifacts {BenchmarkResultsDirectory}");
32+
}
33+
});
34+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
using Nuke.Common;
2+
using Nuke.Common.IO;
3+
using static Nuke.Common.Tools.DotNet.DotNetTasks;
4+
5+
partial class BuildTask
6+
{
7+
AbsolutePath MutationResultsDirectory => ArtifactsDirectory / "mutation-results";
8+
9+
Target MutationTest => _ => _
10+
.DependsOn(Restore)
11+
.Description("Run Stryker.NET mutation testing. Install via: dotnet tool install dotnet-stryker")
12+
.Executes(() =>
13+
{
14+
MutationResultsDirectory.CreateOrCleanDirectory();
15+
16+
var testProjects = RootDirectory.GlobFiles("tests/**/*.csproj");
17+
if (testProjects.Count == 0)
18+
{
19+
Serilog.Log.Warning("No test projects found under tests/. Skipping.");
20+
return;
21+
}
22+
23+
foreach (var testProject in testProjects)
24+
{
25+
DotNet(
26+
$"stryker " +
27+
$"--project {testProject} " +
28+
$"--output {MutationResultsDirectory} " +
29+
$"--reporter html --reporter progress",
30+
workingDirectory: testProject.Parent);
31+
}
32+
});
33+
}

docs/.vitepress/config.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,21 @@ export default defineConfig({
55
description: 'A production-ready .NET project template with built-in CI/CD',
66
base: '/dotnet.CI.template/',
77

8+
sitemap: {
9+
hostname: 'https://agibuild.github.io/dotnet.CI.template'
10+
},
11+
12+
head: [
13+
['meta', { name: 'theme-color', content: '#512bd4' }],
14+
['meta', { property: 'og:type', content: 'website' }],
15+
['meta', { property: 'og:title', content: 'Dotnet.CI.Template' }],
16+
['meta', { property: 'og:description', content: 'A production-ready .NET project template with built-in CI/CD' }],
17+
['meta', { property: 'og:url', content: 'https://agibuild.github.io/dotnet.CI.template/' }],
18+
['meta', { name: 'twitter:card', content: 'summary' }],
19+
['meta', { name: 'twitter:title', content: 'Dotnet.CI.Template' }],
20+
['meta', { name: 'twitter:description', content: 'A production-ready .NET project template with built-in CI/CD' }]
21+
],
22+
823
locales: {
924
root: {
1025
label: 'English',

init.ps1

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
#Requires -Version 7.0
2+
<#
3+
.SYNOPSIS
4+
Initializes this template with your project name via interactive wizard.
5+
.DESCRIPTION
6+
Run without parameters for an interactive guided setup.
7+
Supply -ProjectName and -Force for non-interactive (CI/script) usage.
8+
.EXAMPLE
9+
./init.ps1
10+
.EXAMPLE
11+
./init.ps1 -ProjectName Acme.Payments -Author "Acme Corp" -Force
12+
#>
13+
param(
14+
[ValidatePattern('^[A-Za-z][A-Za-z0-9.]*$')]
15+
[string]$ProjectName,
16+
17+
[string]$Author,
18+
19+
[switch]$ResetGit,
20+
21+
[switch]$Force
22+
)
23+
24+
$ErrorActionPreference = 'Stop'
25+
26+
$OldSample = 'Dotnet.CI.Template.Sample'
27+
$OldSln = 'Dotnet.CI.Template'
28+
$ScriptDir = $PSScriptRoot
29+
30+
# ── Interactive wizard ───────────────────────────────────────────────
31+
32+
if (-not $Force) {
33+
Write-Host ""
34+
Write-Host " dotnet.CI.template - Project Setup" -ForegroundColor Cyan
35+
Write-Host " ===================================" -ForegroundColor Cyan
36+
Write-Host ""
37+
Write-Host " This wizard will customize the template for your project."
38+
Write-Host ""
39+
40+
# Project name
41+
if (-not $ProjectName) {
42+
do {
43+
$ProjectName = Read-Host " ? Project name (e.g. Acme.Payments)"
44+
if ($ProjectName -notmatch '^[A-Za-z][A-Za-z0-9.]*$') {
45+
Write-Host " Must start with a letter and contain only letters, digits, and dots." -ForegroundColor Red
46+
$ProjectName = ''
47+
}
48+
} while (-not $ProjectName)
49+
}
50+
Write-Host " Project name: $ProjectName" -ForegroundColor Green
51+
52+
# Author
53+
if (-not $Author) {
54+
$Author = Read-Host " ? Author / organization (leave blank to skip)"
55+
}
56+
if ($Author) {
57+
Write-Host " Author: $Author" -ForegroundColor Green
58+
}
59+
60+
# Reset git
61+
if (-not $ResetGit) {
62+
$resetAnswer = Read-Host " ? Reset git history to a fresh commit? [y/N]"
63+
$ResetGit = $resetAnswer -match '^[Yy]'
64+
}
65+
66+
# Preview
67+
$affectedFiles = Get-ChildItem -Path $ScriptDir -Recurse -File |
68+
Where-Object {
69+
$path = $_.FullName
70+
$skip = $false
71+
foreach ($d in @('.git', 'node_modules', 'artifacts', 'dist', 'bin', 'obj')) {
72+
if ($path -match [regex]::Escape([IO.Path]::DirectorySeparatorChar + $d + [IO.Path]::DirectorySeparatorChar)) {
73+
$skip = $true; break
74+
}
75+
}
76+
if (-not $skip -and $_.Name -notin @('init.sh', 'init.ps1')) {
77+
$content = Get-Content $_.FullName -Raw -ErrorAction SilentlyContinue
78+
$content -and ($content -match [regex]::Escape($OldSample) -or $content -match [regex]::Escape($OldSln))
79+
}
80+
}
81+
$affectedDirs = Get-ChildItem -Path $ScriptDir -Recurse -Directory |
82+
Where-Object { $_.Name -match [regex]::Escape($OldSample) -and $_.FullName -notmatch '\.git' }
83+
84+
$currentVersion = '(unknown)'
85+
$propsPath = Join-Path $ScriptDir 'Directory.Build.props'
86+
if (Test-Path $propsPath) {
87+
$propsContent = Get-Content $propsPath -Raw
88+
if ($propsContent -match '<VersionPrefix>([^<]+)</VersionPrefix>') {
89+
$currentVersion = $Matches[1]
90+
}
91+
}
92+
93+
Write-Host ""
94+
Write-Host " ──────────────────────────────────────" -ForegroundColor DarkGray
95+
Write-Host ""
96+
Write-Host " The following changes will be applied:" -ForegroundColor Yellow
97+
Write-Host ""
98+
Write-Host " Rename $OldSample -> $ProjectName"
99+
Write-Host " Rename $OldSln.slnx -> $ProjectName.slnx"
100+
if ($Author) {
101+
Write-Host " Update Authors -> $Author"
102+
}
103+
Write-Host " Reset VersionPrefix $currentVersion -> 0.1.0"
104+
Write-Host " Git $(if ($ResetGit) { 'reset to fresh commit' } else { 'preserved' })"
105+
Write-Host " Affect $($affectedFiles.Count) files, $($affectedDirs.Count) directories"
106+
Write-Host ""
107+
Write-Host " WARNING: This operation is IRREVERSIBLE." -ForegroundColor Red
108+
Write-Host ""
109+
110+
$confirm = Read-Host " ? Proceed? [y/N]"
111+
if ($confirm -notmatch '^[Yy]') {
112+
Write-Host ""
113+
Write-Host " Cancelled." -ForegroundColor Yellow
114+
exit 0
115+
}
116+
Write-Host ""
117+
}
118+
else {
119+
if (-not $ProjectName) {
120+
Write-Error "ProjectName is required when using -Force."
121+
exit 1
122+
}
123+
}
124+
125+
# ── Execute ──────────────────────────────────────────────────────────
126+
127+
Write-Host "[1/6] Replacing file contents..."
128+
$excludeDirs = @('.git', 'node_modules', 'artifacts', 'dist', 'bin', 'obj')
129+
Get-ChildItem -Path $ScriptDir -Recurse -File |
130+
Where-Object {
131+
$path = $_.FullName
132+
$skip = $false
133+
foreach ($d in $excludeDirs) {
134+
if ($path -match [regex]::Escape([IO.Path]::DirectorySeparatorChar + $d + [IO.Path]::DirectorySeparatorChar)) {
135+
$skip = $true; break
136+
}
137+
}
138+
-not $skip -and $_.Name -notin @('init.sh', 'init.ps1')
139+
} |
140+
ForEach-Object {
141+
$content = Get-Content $_.FullName -Raw -ErrorAction SilentlyContinue
142+
if ($content -and ($content -match [regex]::Escape($OldSample) -or $content -match [regex]::Escape($OldSln))) {
143+
$content = $content -replace [regex]::Escape($OldSample), $ProjectName
144+
$content = $content -replace [regex]::Escape($OldSln), $ProjectName
145+
Set-Content $_.FullName -Value $content -NoNewline
146+
}
147+
}
148+
149+
# Author update in csproj
150+
if ($Author) {
151+
Write-Host "[2/6] Updating author..."
152+
Get-ChildItem -Path $ScriptDir -Recurse -File -Filter '*.csproj' |
153+
Where-Object { $_.FullName -notmatch '[\\/](_build|build)[\\/]' } |
154+
ForEach-Object {
155+
$content = Get-Content $_.FullName -Raw -ErrorAction SilentlyContinue
156+
if ($content -and $content -match '<Authors>[^<]*</Authors>') {
157+
$content = $content -replace '<Authors>[^<]*</Authors>', "<Authors>$Author</Authors>"
158+
Set-Content $_.FullName -Value $content -NoNewline
159+
}
160+
}
161+
}
162+
else {
163+
Write-Host "[2/6] Skipping author update..."
164+
}
165+
166+
# Rename directories (deepest first)
167+
Write-Host "[3/6] Renaming directories..."
168+
Get-ChildItem -Path $ScriptDir -Recurse -Directory |
169+
Where-Object { $_.Name -match [regex]::Escape($OldSample) -and $_.FullName -notmatch '\.git' } |
170+
Sort-Object { $_.FullName.Length } -Descending |
171+
ForEach-Object {
172+
$newName = $_.Name -replace [regex]::Escape($OldSample), $ProjectName
173+
$newPath = Join-Path $_.Parent.FullName $newName
174+
Write-Host " $($_.FullName) -> $newPath"
175+
Rename-Item $_.FullName $newPath
176+
}
177+
178+
# Rename files
179+
Write-Host "[4/6] Renaming files..."
180+
Get-ChildItem -Path $ScriptDir -Recurse -File |
181+
Where-Object { $_.Name -match [regex]::Escape($OldSample) -and $_.FullName -notmatch '\.git' } |
182+
ForEach-Object {
183+
$newName = $_.Name -replace [regex]::Escape($OldSample), $ProjectName
184+
$newPath = Join-Path $_.Directory.FullName $newName
185+
Write-Host " $($_.FullName) -> $newPath"
186+
Rename-Item $_.FullName $newPath
187+
}
188+
189+
$slnFile = Join-Path $ScriptDir "$OldSln.slnx"
190+
if (Test-Path $slnFile) {
191+
$newSlnFile = Join-Path $ScriptDir "$ProjectName.slnx"
192+
Rename-Item $slnFile $newSlnFile
193+
Write-Host " $OldSln.slnx -> $ProjectName.slnx"
194+
}
195+
196+
# Reset version
197+
Write-Host "[5/6] Resetting version to 0.1.0..."
198+
$propsFile = Join-Path $ScriptDir 'Directory.Build.props'
199+
if (Test-Path $propsFile) {
200+
$xml = Get-Content $propsFile -Raw
201+
$xml = $xml -replace '<VersionPrefix>[^<]*</VersionPrefix>', '<VersionPrefix>0.1.0</VersionPrefix>'
202+
Set-Content $propsFile -Value $xml -NoNewline
203+
}
204+
205+
# Update build paths
206+
Write-Host "[6/6] Updating build configuration..."
207+
$paramsFile = Join-Path $ScriptDir 'build/BuildTask.Parameters.cs'
208+
if (Test-Path $paramsFile) {
209+
$cs = Get-Content $paramsFile -Raw
210+
$cs = $cs -replace [regex]::Escape("$OldSln.slnx"), "$ProjectName.slnx"
211+
Set-Content $paramsFile -Value $cs -NoNewline
212+
}
213+
214+
# Optional: reset git history
215+
if ($ResetGit) {
216+
Write-Host ""
217+
Write-Host "Resetting git history..."
218+
Remove-Item (Join-Path $ScriptDir '.git') -Recurse -Force
219+
git -C $ScriptDir init
220+
git -C $ScriptDir add .
221+
git -C $ScriptDir commit -m "Initial commit from dotnet.CI.template"
222+
}
223+
224+
# Clean up init scripts
225+
Remove-Item (Join-Path $ScriptDir 'init.sh') -ErrorAction SilentlyContinue
226+
Remove-Item (Join-Path $ScriptDir 'init.ps1') -ErrorAction SilentlyContinue
227+
228+
Write-Host ""
229+
Write-Host "Done! Your project '$ProjectName' is ready." -ForegroundColor Green
230+
Write-Host ""
231+
Write-Host "Next steps:"
232+
Write-Host " 1. Update GitHub URL in docs/.vitepress/config.ts"
233+
Write-Host " 2. Run: dotnet restore --force-evaluate"
234+
Write-Host " 3. Run: dotnet build"

0 commit comments

Comments
 (0)