Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: Build

on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]

jobs:
build:
runs-on: windows-latest
permissions:
contents: read

steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Run Nuke build
run: .\build.ps1 Default

# The Nuke build and Jekyll build are independent:
# - Nuke compiles the guidelines into standalone HTML documents using Pandoc
# - Jekyll builds the static website from the same source Markdown files
# They share no artifacts and can run in parallel.
jekyll:
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want this to be part of the Nuke script as well.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in commit 509774e. Added a JekyllBuild target to build/Build.cs:

Target JekyllBuild => _ => _
    .Executes(() =>
    {
        ProcessTasks.StartProcess("bundle", "exec jekyll build", RootDirectory)
            .AssertZeroExitCode();
    });

The jekyll CI job now invokes this via ./build.sh JekyllBuild instead of calling bundle exec jekyll build directly.

Copy link
Copy Markdown
Owner

@dennisdoomen dennisdoomen Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot Then you can remove it from the build.yml, right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in commit f16399c. ruby/setup-ruby@v1 is removed from the workflow. The jekyll job now just does checkout + ./build.sh JekyllBuild.

runs-on: ubuntu-latest
permissions:
contents: read

steps:
- uses: actions/checkout@v4
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot Running Jekyll should also become part of the Nuke script

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in commit f16399c. bundle install is now part of the JekyllBuild Nuke target, so the Nuke script handles the full Jekyll setup:

Target JekyllBuild => _ => _
    .Executes(() =>
    {
        ProcessTasks.StartProcess("bundle", "install", RootDirectory)
            .AssertZeroExitCode();

        ProcessTasks.StartProcess("bundle", "exec jekyll build", RootDirectory)
            .AssertZeroExitCode();
    });


- name: Build Jekyll site via Nuke
run: ./build.sh JekyllBuild
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,8 @@ _site

.idea/
/node_modules/

# Nuke build artifacts
/build/bin/
/build/obj/
/.nuke/temp/
76 changes: 76 additions & 0 deletions build.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
[CmdletBinding()]
Param(
[Parameter(Position=0,Mandatory=$false,ValueFromRemainingArguments=$true)]
[string[]]$BuildArguments
)

Write-Output "PowerShell $($PSVersionTable.PSEdition) version $($PSVersionTable.PSVersion)"

Set-StrictMode -Version 2.0; $ErrorActionPreference = "Stop"; $ConfirmPreference = "None"; trap { Write-Error $_ -ErrorAction Continue; exit 1 }
$PSScriptRoot = Split-Path $MyInvocation.MyCommand.Path -Parent

###########################################################################
# CONFIGURATION
###########################################################################

$BuildProjectFile = "$PSScriptRoot\build\_build.csproj"
$TempDirectory = "$PSScriptRoot\.nuke\temp"

$DotNetGlobalFile = "$PSScriptRoot\global.json"
$DotNetInstallUrl = "https://dot.net/v1/dotnet-install.ps1"
$DotNetChannel = "STS"

$env:DOTNET_CLI_TELEMETRY_OPTOUT = 1
$env:DOTNET_NOLOGO = 1
$env:DOTNET_ROLL_FORWARD = "Major"
$env:NUKE_TELEMETRY_OPTOUT = 1

###########################################################################
# EXECUTION
###########################################################################

function ExecSafe([scriptblock] $cmd) {
& $cmd
if ($LASTEXITCODE) { exit $LASTEXITCODE }
}

# Check if any dotnet is installed
if ($null -ne (Get-Command "dotnet" -ErrorAction SilentlyContinue)) {
ExecSafe { & dotnet --info }
}

# If dotnet CLI is installed globally and it matches requested version, use for execution
if ($null -ne (Get-Command "dotnet" -ErrorAction SilentlyContinue) -and `
$(dotnet --version) -and $LASTEXITCODE -eq 0) {
$env:DOTNET_EXE = (Get-Command "dotnet").Path
}
else {
# Download install script
$DotNetInstallFile = "$TempDirectory\dotnet-install.ps1"
New-Item -ItemType Directory -Path $TempDirectory -Force | Out-Null
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
(New-Object System.Net.WebClient).DownloadFile($DotNetInstallUrl, $DotNetInstallFile)

# If global.json exists, load expected version
if (Test-Path $DotNetGlobalFile) {
$DotNetGlobal = $(Get-Content $DotNetGlobalFile | Out-String | ConvertFrom-Json)
if ($DotNetGlobal.PSObject.Properties["sdk"] -and $DotNetGlobal.sdk.PSObject.Properties["version"]) {
$DotNetVersion = $DotNetGlobal.sdk.version
}
}

# Install by channel or version
$DotNetDirectory = "$TempDirectory\dotnet-win"
if (!(Test-Path variable:DotNetVersion)) {
ExecSafe { & powershell $DotNetInstallFile -InstallDir $DotNetDirectory -Channel $DotNetChannel -NoPath }
} else {
ExecSafe { & powershell $DotNetInstallFile -InstallDir $DotNetDirectory -Version $DotNetVersion -NoPath }
}
$env:DOTNET_EXE = "$DotNetDirectory\dotnet.exe"
$env:PATH = "$DotNetDirectory;$env:PATH"
}

Write-Output "Microsoft (R) .NET SDK version $(& $env:DOTNET_EXE --version)"

ExecSafe { & $env:DOTNET_EXE build $BuildProjectFile /nodeReuse:false /p:UseSharedCompilation=false -nologo -clp:NoSummary }
ExecSafe { & $env:DOTNET_EXE run --project $BuildProjectFile --no-build -- $BuildArguments }
69 changes: 69 additions & 0 deletions build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#!/usr/bin/env bash

bash --version 2>&1 | head -n 1

set -eo pipefail
SCRIPT_DIR=$(cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd)

###########################################################################
# CONFIGURATION
###########################################################################

BUILD_PROJECT_FILE="$SCRIPT_DIR/build/_build.csproj"
TEMP_DIRECTORY="$SCRIPT_DIR/.nuke/temp"

DOTNET_GLOBAL_FILE="$SCRIPT_DIR/global.json"
DOTNET_INSTALL_URL="https://dot.net/v1/dotnet-install.sh"
DOTNET_CHANNEL="STS"

export DOTNET_CLI_TELEMETRY_OPTOUT=1
export DOTNET_NOLOGO=1
export DOTNET_ROLL_FORWARD="Major"
export NUKE_TELEMETRY_OPTOUT=1

###########################################################################
# EXECUTION
###########################################################################

function FirstJsonValue {
perl -nle 'print $1 if m{"'"$1"'": "([^"]+)",?}' <<< "${@:2}"
}

# Check if any dotnet is installed
if [[ -x "$(command -v dotnet)" ]]; then
dotnet --info
fi

# If dotnet CLI is installed globally and it matches requested version, use for execution
if [ -x "$(command -v dotnet)" ] && dotnet --version &>/dev/null; then
export DOTNET_EXE="$(command -v dotnet)"
else
# Download install script
DOTNET_INSTALL_FILE="$TEMP_DIRECTORY/dotnet-install.sh"
mkdir -p "$TEMP_DIRECTORY"
curl -Lsfo "$DOTNET_INSTALL_FILE" "$DOTNET_INSTALL_URL"
chmod +x "$DOTNET_INSTALL_FILE"

# If global.json exists, load expected version
if [[ -f "$DOTNET_GLOBAL_FILE" ]]; then
DOTNET_VERSION=$(FirstJsonValue "version" "$(cat "$DOTNET_GLOBAL_FILE")")
if [[ "$DOTNET_VERSION" == "" ]]; then
unset DOTNET_VERSION
fi
fi

# Install by channel or version
DOTNET_DIRECTORY="$TEMP_DIRECTORY/dotnet-unix"
if [[ -z ${DOTNET_VERSION+x} ]]; then
"$DOTNET_INSTALL_FILE" --install-dir "$DOTNET_DIRECTORY" --channel "$DOTNET_CHANNEL" --no-path
else
"$DOTNET_INSTALL_FILE" --install-dir "$DOTNET_DIRECTORY" --version "$DOTNET_VERSION" --no-path
fi
export DOTNET_EXE="$DOTNET_DIRECTORY/dotnet"
export PATH="$DOTNET_DIRECTORY:$PATH"
fi

echo "Microsoft (R) .NET SDK version $("$DOTNET_EXE" --version)"

"$DOTNET_EXE" build "$BUILD_PROJECT_FILE" /nodeReuse:false /p:UseSharedCompilation=false -nologo -clp:NoSummary
"$DOTNET_EXE" run --project "$BUILD_PROJECT_FILE" --no-build -- "$@"
185 changes: 185 additions & 0 deletions build/Build.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
using System;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Nuke.Common;
using Nuke.Common.IO;
using Nuke.Common.Tooling;
using Nuke.Common.Tools.GitVersion;
using Serilog;

using GitVersionInfo = Nuke.Common.Tools.GitVersion.GitVersion;

class Build : NukeBuild
{
public static int Main() => Execute<Build>(x => x.Default);

AbsolutePath ArtifactsDirectory => RootDirectory / "Artifacts";
AbsolutePath LibDirectory => RootDirectory / "Lib";

const string DefaultRulePrefix = "AV";

string semVer = string.Empty;
string commitDate = string.Empty;

Target Clean => _ => _
.Executes(() =>
{
ArtifactsDirectory.CreateOrCleanDirectory();
});

Target ExtractVersionsFromGit => _ => _
.DependsOn(Clean)
.Executes(() =>
{
var result = GitVersionTasks.GitVersion(s => s
.SetProcessToolPath(LibDirectory / "GitVersion.exe")
.SetProcessWorkingDirectory(RootDirectory));

semVer = result.Result.SemVer;
commitDate = DateTime.Parse(result.Result.CommitDate).ToString("MMMM d, yyyy");
});

Target Compile => _ => _
.DependsOn(ExtractVersionsFromGit)
.Executes(() =>
{
var guidelineFiles = new AbsolutePath[]
{
RootDirectory / "_pages" / "0000_CoverAndStyles.md",
RootDirectory / "_includes" / "0001_Introduction.md",
RootDirectory / "_pages" / "1000_ClassDesignGuidelines.md",
RootDirectory / "_pages" / "1100_MemberDesignGuidelines.md",
RootDirectory / "_pages" / "1200_MiscellaneousDesignGuidelines.md",
RootDirectory / "_pages" / "1500_MaintainabilityGuidelines.md",
RootDirectory / "_pages" / "1700_NamingGuidelines.md",
RootDirectory / "_pages" / "1800_PerformanceGuidelines.md",
RootDirectory / "_pages" / "2200_FrameworkGuidelines.md",
RootDirectory / "_pages" / "2300_DocumentationGuidelines.md",
RootDirectory / "_pages" / "2400_LayoutGuidelines.md",
RootDirectory / "_pages" / "9999_ResourcesAndLinks.md",
};

var outputDir = ArtifactsDirectory / "Guidelines";
outputDir.CreateOrCleanDirectory();

var outfile = outputDir / "CSharpCodingGuidelines.md";

foreach (var file in guidelineFiles)
{
var rawContent = File.ReadAllText(file)
.Replace("%semver%", semVer)
.Replace("%commitdate%", commitDate)
.Replace("![](/assets", "![](assets");

var title = ExtractFrontmatterField(rawContent, "title");
var category = ExtractFrontmatterField(rawContent, "rule_category");

rawContent = RemoveFrontmatter(rawContent);

string content;
if (string.IsNullOrEmpty(category))
{
Log.Information("Including {File}", (string)file);
content = rawContent;
}
else
{
Log.Information("Including rules of category {Category}", category);
content = string.Empty;

foreach (var ruleFile in (RootDirectory / "_rules").GetFiles().OrderBy(f => (string)f))
{
var rule = File.ReadAllText(ruleFile);
if (!Regex.IsMatch(rule, $@"---(.|\n)*rule_category\: {Regex.Escape(category)}"))
continue;

var ruleTitle = ExtractFrontmatterField(rule, "title");
var ruleSeverity = ExtractFrontmatterField(rule, "severity");
var ruleId = ExtractFrontmatterField(rule, "rule_id");
var ruleIdPrefix = ExtractFrontmatterField(rule, "custom_prefix") is { Length: > 0 } cp
? cp
: "{{ site.default_rule_prefix }}";

content += $"<div id=\"{ruleIdPrefix}{ruleId}\"></div>### {ruleTitle} ({ruleIdPrefix}{ruleId}) <img src=\"assets/images/{ruleSeverity}.png\" />\n";
content += RemoveFrontmatter(rule);
content += "\n";
}
}

content = content.Replace("{{ site.default_rule_prefix }}", DefaultRulePrefix);
content = Regex.Replace(content, @"\(\/.+?(#\w+)\)", "($1)");

if (!string.IsNullOrEmpty(title))
content = $"<h1>{title}</h1>\n" + content;

File.AppendAllText(outfile, content);
}

(RootDirectory / "assets" / "css" / "Guidelines.css")
.Copy(outputDir / "style.css", ExistsPolicy.FileOverwrite);
(RootDirectory / "assets" / "images")
.Copy(outputDir / "assets" / "images", ExistsPolicy.MergeAndOverwrite);
});

Target CompileCheatsheet => _ => _
.DependsOn(ExtractVersionsFromGit)
.Executes(() =>
{
var outputDir = ArtifactsDirectory / "Cheatsheet";
outputDir.CreateOrCleanDirectory();

var content = File.ReadAllText(RootDirectory / "_pages" / "Cheatsheet.md")
.Replace("%semver%", semVer)
.Replace("%commitdate%", commitDate)
.Replace("{{ site.default_rule_prefix }}", DefaultRulePrefix);

File.WriteAllText(outputDir / "Cheatsheet.md", content);

(RootDirectory / "assets" / "css" / "CheatSheet.css")
.Copy(outputDir / "style.css", ExistsPolicy.FileOverwrite);
(RootDirectory / "assets" / "images")
.Copy(outputDir / "assets" / "images", ExistsPolicy.MergeAndOverwrite);
});

Target BuildHtml => _ => _
.DependsOn(Compile, CompileCheatsheet)
.Executes(() =>
{
var pandoc = LibDirectory / "Pandoc" / "pandoc.exe";

ProcessTasks.StartProcess(
pandoc,
$"CSharpCodingGuidelines.md -f markdown_phpextra -s -o \"{ArtifactsDirectory / "CSharpCodingGuidelines.htm"}\" --self-contained",
ArtifactsDirectory / "Guidelines"
).AssertZeroExitCode();

ProcessTasks.StartProcess(
pandoc,
$"Cheatsheet.md -f markdown+markdown_in_html_blocks -s -o \"{ArtifactsDirectory / "CSharpCodingGuidelinesCheatsheet.htm"}\" --self-contained",
ArtifactsDirectory / "Cheatsheet"
).AssertZeroExitCode();
});

Target JekyllBuild => _ => _
.Executes(() =>
{
ProcessTasks.StartProcess("bundle", "install", RootDirectory)
.AssertZeroExitCode();

ProcessTasks.StartProcess("bundle", "exec jekyll build", RootDirectory)
.AssertZeroExitCode();
});

Target Default => _ => _
.DependsOn(BuildHtml);

static string ExtractFrontmatterField(string content, string fieldName)
{
var match = Regex.Match(content, $@"---(.|\n)*{Regex.Escape(fieldName)}\: (.+)");
return match.Success ? match.Groups[2].Value.Trim() : string.Empty;
}

static string RemoveFrontmatter(string content) =>
Regex.Replace(content, @"---\r?\n(.|\r?\n)+?---\r?\n", string.Empty);
}
15 changes: 15 additions & 0 deletions build/_build.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>_build</RootNamespace>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Nuke.Common" Version="8.1.4" />
</ItemGroup>

</Project>
Loading