diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..3c835b7c --- /dev/null +++ b/.github/workflows/build.yml @@ -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: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + + - name: Build Jekyll site via Nuke + run: ./build.sh JekyllBuild diff --git a/.gitignore b/.gitignore index 731bb24e..c12786a3 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,8 @@ _site .idea/ /node_modules/ + +# Nuke build artifacts +/build/bin/ +/build/obj/ +/.nuke/temp/ diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 00000000..32eff7ce --- /dev/null +++ b/build.ps1 @@ -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 } diff --git a/build.sh b/build.sh new file mode 100755 index 00000000..9692064d --- /dev/null +++ b/build.sh @@ -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 -- "$@" diff --git a/build/Build.cs b/build/Build.cs new file mode 100644 index 00000000..82915b91 --- /dev/null +++ b/build/Build.cs @@ -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(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 += $"
### {ruleTitle} ({ruleIdPrefix}{ruleId}) \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 = $"

{title}

\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); +} diff --git a/build/_build.csproj b/build/_build.csproj new file mode 100644 index 00000000..56c73298 --- /dev/null +++ b/build/_build.csproj @@ -0,0 +1,15 @@ + + + + Exe + net8.0 + _build + enable + false + + + + + + +